summaryrefslogtreecommitdiffstats
path: root/src/Cepis
diff options
context:
space:
mode:
Diffstat (limited to 'src/Cepis')
-rw-r--r--src/Cepis/CepNotFoundException.cs44
-rw-r--r--src/Cepis/Cepis.csproj2
-rw-r--r--src/Cepis/CepisClient.cs70
-rw-r--r--src/Cepis/Faixa.cs99
-rw-r--r--src/Cepis/FaixaReader.cs98
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,
+ };
+ }
+}