diff options
Diffstat (limited to 'src/Cepis')
| -rw-r--r-- | src/Cepis/CepNotFoundException.cs | 44 | ||||
| -rw-r--r-- | src/Cepis/Cepis.csproj | 2 | ||||
| -rw-r--r-- | src/Cepis/CepisClient.cs | 70 | ||||
| -rw-r--r-- | src/Cepis/Faixa.cs | 99 | ||||
| -rw-r--r-- | src/Cepis/FaixaReader.cs | 98 |
5 files changed, 305 insertions, 8 deletions
diff --git a/src/Cepis/CepNotFoundException.cs b/src/Cepis/CepNotFoundException.cs index ced7b16..19a4576 100644 --- a/src/Cepis/CepNotFoundException.cs +++ b/src/Cepis/CepNotFoundException.cs @@ -4,24 +4,60 @@ namespace Cepis; /// The CEP is well-formed but does not exist in the DNE database. /// </summary> /// <remarks> +/// <para> /// Retrying does not help: the answer is definitive until the next fortnightly /// database. A batch never throws this — a CEP with no address is /// <see langword="null"/> at its position instead. +/// </para> +/// <para> +/// <see cref="Faixa"/> carries the municipality the CEP would have belonged to, +/// when the API can name one. It is context for the failure and not a fallback +/// address: this is still a 404. +/// </para> /// </remarks> public sealed class CepNotFoundException : CepisException { - /// <summary>Create the exception with a message.</summary> + /// <summary>Create the exception with a message and the range, if any.</summary> /// <param name="message">what went wrong</param> - public CepNotFoundException(string message) + /// <param name="faixa"> + /// the municipality the CEP would belong to, or <see langword="null"/> when + /// the API named none + /// </param> + public CepNotFoundException(string message, Faixa? faixa = null) : base(message) { + Faixa = faixa; } - /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <summary>Create the exception with a message, the range and a cause.</summary> /// <param name="message">what went wrong</param> + /// <param name="faixa">the municipality the CEP would belong to, if any</param> /// <param name="innerException">the failure underneath</param> - public CepNotFoundException(string message, Exception? innerException) + public CepNotFoundException(string message, Faixa? faixa, Exception? innerException) : base(message, innerException) { + Faixa = faixa; } + + /// <summary> + /// The municipality the CEP would have belonged to, or + /// <see langword="null"/> when there is none. + /// </summary> + /// <remarks> + /// <para> + /// <see langword="null"/> is the ordinary case for a CEP falling in a band + /// no municipality owns: the API <b>omits</b> the block rather than sending + /// a null, and an older API does not send it at all. Anything unreadable in + /// it lands here as <see langword="null"/> too — a courtesy field is not + /// worth turning a clean not-found into a parse error over. + /// </para> + /// <para> + /// It says which municipality the number <i>would</i> be in, not that any + /// address exists. Read <see cref="Cepis.Faixa"/> before using + /// <see cref="Cepis.Faixa.CepIni"/> and <see cref="Cepis.Faixa.CepFim"/>: + /// they bound the CEPs that exist there, and the range between them is + /// mostly empty. + /// </para> + /// </remarks> + public Faixa? Faixa { get; } } diff --git a/src/Cepis/Cepis.csproj b/src/Cepis/Cepis.csproj index 16ffb75..e0ba207 100644 --- a/src/Cepis/Cepis.csproj +++ b/src/Cepis/Cepis.csproj @@ -15,7 +15,7 @@ <PropertyGroup> <PackageId>Cepis</PackageId> - <Version>0.1.0</Version> + <Version>0.2.0</Version> <Authors>Murilo Ijanc</Authors> <Description>Client for the public CEP API at cep.is. Official Correios data (DNE), refreshed every two weeks.</Description> <PackageLicenseExpression>ISC</PackageLicenseExpression> diff --git a/src/Cepis/CepisClient.cs b/src/Cepis/CepisClient.cs index 72637c0..e32b78e 100644 --- a/src/Cepis/CepisClient.cs +++ b/src/Cepis/CepisClient.cs @@ -54,7 +54,7 @@ public sealed class CepisClient : IDisposable public const int MaxBatch = 500; /// <summary>The version of this library.</summary> - public const string Version = "0.1.0"; + public const string Version = "0.2.0"; private const string AcceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1"; @@ -175,9 +175,17 @@ public sealed class CepisClient : IDisposable /// Look up a single CEP. /// </summary> /// <remarks> + /// <para> /// This is the only call that carries a coordinate. About 57% of CEPs have /// one; when they do not, <see cref="Address.Geo"/> is /// <see langword="null"/>. + /// </para> + /// <para> + /// A CEP that does not exist throws, as it always has. What is new is that + /// the exception may name the municipality the number would belong to, in + /// <see cref="CepNotFoundException.Faixa"/> — context for the failure, not + /// an address, and never a reason to treat the lookup as having succeeded. + /// </para> /// </remarks> /// <param name="cep">the CEP, masked or not</param> /// <param name="cancellationToken">cancels the call, waits included</param> @@ -186,7 +194,10 @@ public sealed class CepisClient : IDisposable /// the CEP is not eight digits, in which case no request is made, or the API /// rejected it with a 400 /// </exception> - /// <exception cref="CepNotFoundException">well-formed, but not in the database</exception> + /// <exception cref="CepNotFoundException"> + /// well-formed, but not in the database; carries the municipality it would + /// belong to when the API names one + /// </exception> /// <exception cref="RateLimitedException">the API answered 429 on every attempt</exception> /// <exception cref="ServerErrorException">the API answered 5xx on every attempt</exception> /// <exception cref="NetworkException">the request never completed</exception> @@ -198,7 +209,8 @@ public sealed class CepisClient : IDisposable if (reply.Status == 404) { - throw new CepNotFoundException($"CEP {number} does not exist in the DNE database"); + var faixa = ReadFaixa(reply.Body); + throw new CepNotFoundException(NotFoundMessage(number, faixa), faixa); } if (reply.Status == 400) @@ -459,6 +471,58 @@ public sealed class CepisClient : IDisposable return fallback; } + /// <summary> + /// Read the <c>faixa</c> block out of a 404 body, when it has one. + /// </summary> + /// <remarks> + /// The block is optional and always will be: the API omits it — it does not + /// send a null — for a CEP in a band no municipality owns. Absent, + /// misshapen or unreadable, the answer here is <see langword="null"/> and + /// the caller still gets the plain not-found it was going to get anyway. + /// </remarks> + /// <param name="body">the 404 body</param> + /// <returns>the municipality the CEP would belong to, or null</returns> + private static Faixa? ReadFaixa(byte[] body) + { + try + { + using var document = JsonDocument.Parse(new ReadOnlyMemory<byte>(body)); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("faixa", out var faixa)) + { + return FaixaReader.Read(faixa); + } + } + catch (JsonException) + { + return null; + } + + return null; + } + + /// <summary> + /// What to tell the caller about a CEP that is not in the database. + /// </summary> + /// <remarks> + /// The municipality goes in the message when the API named one, because the + /// message is what ends up in a log, and "the range belongs to Areal/RJ" is + /// the difference between a dead end and a question worth asking the user. + /// The count is in it for the same reason the API sends it: so nobody reads + /// the two ends as a list of CEPs to walk. + /// </remarks> + /// <param name="cep">the eight digits</param> + /// <param name="faixa">the municipality it would belong to, if any</param> + /// <returns>the message</returns> + private static string NotFoundMessage(string cep, Faixa? faixa) + { + var message = $"CEP {cep} does not exist in the DNE database"; + return faixa is null + ? message + : message + $" — the range belongs to {faixa.Localidade}/{faixa.UF}, " + + $"which has {faixa.Ceps} CEP(s) between {faixa.CepIni} and {faixa.CepFim}"; + } + private static string Preview(byte[] body) => Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 120)); diff --git a/src/Cepis/Faixa.cs b/src/Cepis/Faixa.cs new file mode 100644 index 0000000..e2477b6 --- /dev/null +++ b/src/Cepis/Faixa.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// The municipality a CEP that does not exist would have belonged to. +/// </summary> +/// <remarks> +/// <para> +/// This is <b>context for an error, never an address</b>. It rides on the 404 +/// of <see cref="CepisClient.LookupAsync(string, CancellationToken)"/>, hanging +/// off <see cref="CepNotFoundException.Faixa"/>. The CEP that was asked for +/// still does not exist, and nothing here says otherwise: filling a form from +/// these fields would be inventing an address. +/// </para> +/// <para> +/// It is worth having because a CEP that fails is rarely random — it is usually +/// someone who knows the city and guessed the number. The classic guess is the +/// <c>-000</c>, which 957 municipalities never received. Knowing where the +/// guess landed is what turns "CEP not found" into "did you mean a street in +/// Areal/RJ?". +/// </para> +/// <para> +/// <see cref="CepIni"/> and <see cref="CepFim"/> are the smallest and the +/// largest CEP that <b>actually exist</b> in that municipality, <i>not</i> the +/// bounds the Correios publish. For Areal the DNE's own range starts at +/// <c>25845000</c> — exactly the CEP that just failed — while +/// <see cref="CepIni"/> is <c>25845001</c>. +/// </para> +/// <para> +/// The range is <b>sparse</b>, and the pair is not a list of valid CEPs. Areal +/// has 118 of them (<see cref="Ceps"/>) spread across the ~4,900 numbers +/// between its two ends, so a number picked in between overwhelmingly does not +/// exist either. Never iterate the interval, and never show it as a set of +/// usable CEPs. +/// </para> +/// <para> +/// The property names are the API's own, in Portuguese, with only the casing +/// adapted — the same rule the rest of the data follows. On the wire they are +/// <c>localidade</c>, <c>uf</c>, <c>estado</c>, <c>regiao</c>, <c>ibge</c>, +/// <c>cep_ini</c>, <c>cep_fim</c> and <c>ceps</c>. +/// </para> +/// </remarks> +public sealed record Faixa +{ + /// <summary>The municipality. Wire name <c>localidade</c>.</summary> + [JsonPropertyName("localidade")] + public string Localidade { get; init; } = ""; + + /// <summary>The two-letter state code. Wire name <c>uf</c>.</summary> + [JsonPropertyName("uf")] + public string UF { get; init; } = ""; + + /// <summary>The state's name. Wire name <c>estado</c>.</summary> + [JsonPropertyName("estado")] + public string Estado { get; init; } = ""; + + /// <summary>The region of the country. Wire name <c>regiao</c>.</summary> + [JsonPropertyName("regiao")] + public string Regiao { get; init; } = ""; + + /// <summary>The IBGE municipality code. Wire name <c>ibge</c>.</summary> + [JsonPropertyName("ibge")] + public string Ibge { get; init; } = ""; + + /// <summary> + /// The smallest CEP that exists in that municipality, unmasked. Wire name + /// <c>cep_ini</c>. + /// </summary> + /// <remarks> + /// The smallest one <i>in the database</i>, not the start of the range the + /// Correios publish — the published start may itself be a CEP that was never + /// issued, which is often precisely the one the caller just tried. + /// </remarks> + [JsonPropertyName("cep_ini")] + public string CepIni { get; init; } = ""; + + /// <summary> + /// The largest CEP that exists in that municipality, unmasked. Wire name + /// <c>cep_fim</c>. + /// </summary> + /// <remarks> + /// Together with <see cref="CepIni"/> it bounds the CEPs of the + /// municipality; it does not enumerate them. See <see cref="Ceps"/>. + /// </remarks> + [JsonPropertyName("cep_fim")] + public string CepFim { get; init; } = ""; + + /// <summary> + /// How many CEPs the municipality actually has. Wire name <c>ceps</c>. + /// </summary> + /// <remarks> + /// This field exists to keep <see cref="CepIni"/> and <see cref="CepFim"/> + /// from being read as a contiguous interval. Areal's 118 CEPs sit inside a + /// span of about 4,900 numbers: the range is mostly empty. + /// </remarks> + [JsonPropertyName("ceps")] + public int Ceps { get; init; } +} diff --git a/src/Cepis/FaixaReader.cs b/src/Cepis/FaixaReader.cs new file mode 100644 index 0000000..da3c6a2 --- /dev/null +++ b/src/Cepis/FaixaReader.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text.Json; + +namespace Cepis; + +/// <summary> +/// Reads the <c>faixa</c> block of a 404 body, yielding <see langword="null"/> +/// for anything it cannot make sense of. +/// </summary> +/// <remarks> +/// <para> +/// A reader rather than a <see cref="System.Text.Json.Serialization.JsonConverter{T}"/> +/// because <c>faixa</c> is not a field of any model this library deserializes: +/// it hangs off an error body that is otherwise only read for its message. +/// </para> +/// <para> +/// Nothing in here throws. The block is a courtesy on top of a 404 — a missing, +/// misshapen or half-empty one must leave the caller with the same clean +/// <see cref="CepNotFoundException"/> it would have got before the API grew the +/// field, never with a parse failure on top of a not-found. +/// </para> +/// </remarks> +internal static class FaixaReader +{ + /// <summary>Turn the <c>faixa</c> element into a range, or null.</summary> + /// <param name="element">the block, as it came off the wire</param> + /// <returns>the range, or null when there is nothing usable in it</returns> + internal static Faixa? Read(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + var localidade = ReadString(element, "localidade"); + if (string.IsNullOrWhiteSpace(localidade)) + { + return null; + } + + return new Faixa + { + Localidade = localidade, + UF = ReadString(element, "uf"), + Estado = ReadString(element, "estado"), + Regiao = ReadString(element, "regiao"), + Ibge = ReadString(element, "ibge"), + CepIni = ReadString(element, "cep_ini"), + CepFim = ReadString(element, "cep_fim"), + Ceps = ReadInt(element, "ceps"), + }; + } + + /// <summary> + /// The property as text, or the empty string when it is absent or of a + /// shape that is not text. + /// </summary> + /// <remarks> + /// A number is accepted and taken verbatim, the way + /// <see cref="LenientStringConverter"/> does it for the address fields. + /// </remarks> + private static string ReadString(JsonElement owner, string name) + { + if (!owner.TryGetProperty(name, out var element)) + { + return ""; + } + + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? "", + JsonValueKind.Number => element.GetRawText(), + _ => "", + }; + } + + /// <summary>The property as a count, or zero when it cannot be read.</summary> + private static int ReadInt(JsonElement owner, string name) + { + if (!owner.TryGetProperty(name, out var element)) + { + return 0; + } + + return element.ValueKind switch + { + JsonValueKind.Number => element.TryGetInt32(out var number) ? number : 0, + JsonValueKind.String => int.TryParse( + element.GetString(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var parsed) + ? parsed + : 0, + _ => 0, + }; + } +} |