diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:38 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 13:10:34 -0300 |
| commit | a2b10fd494fb389e556e40134031eb86e56e11e7 (patch) | |
| tree | 08b6df8a06f2c7c15488b90d57a6d8286cd57a22 /src | |
| download | csharp-c6b417fa15f1f27beb4b77bfda58c738f6b4b861.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/Cepis/Address.cs | 205 | ||||
| -rw-r--r-- | src/Cepis/AssemblyInfo.cs | 3 | ||||
| -rw-r--r-- | src/Cepis/CepNotFoundException.cs | 27 | ||||
| -rw-r--r-- | src/Cepis/Cepis.csproj | 33 | ||||
| -rw-r--r-- | src/Cepis/CepisClient.cs | 714 | ||||
| -rw-r--r-- | src/Cepis/CepisClientOptions.cs | 92 | ||||
| -rw-r--r-- | src/Cepis/CepisException.cs | 39 | ||||
| -rw-r--r-- | src/Cepis/Csv.cs | 122 | ||||
| -rw-r--r-- | src/Cepis/Geo.cs | 67 | ||||
| -rw-r--r-- | src/Cepis/GeoConverter.cs | 89 | ||||
| -rw-r--r-- | src/Cepis/Health.cs | 51 | ||||
| -rw-r--r-- | src/Cepis/InvalidCepException.cs | 29 | ||||
| -rw-r--r-- | src/Cepis/InvalidResponseException.cs | 27 | ||||
| -rw-r--r-- | src/Cepis/LenientStringConverter.cs | 57 | ||||
| -rw-r--r-- | src/Cepis/NetworkException.cs | 27 | ||||
| -rw-r--r-- | src/Cepis/RateLimitedException.cs | 39 | ||||
| -rw-r--r-- | src/Cepis/ServerErrorException.cs | 29 |
17 files changed, 1650 insertions, 0 deletions
diff --git a/src/Cepis/Address.cs b/src/Cepis/Address.cs new file mode 100644 index 0000000..edc7776 --- /dev/null +++ b/src/Cepis/Address.cs @@ -0,0 +1,205 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// A CEP and everything the database knows about it. +/// </summary> +/// <remarks> +/// <para> +/// The property names are the API's own, in Portuguese, with only the casing +/// adapted to C#. Everything else in this library is English; the data fields +/// are the one deliberate exception, so that what you read in the JSON at +/// <c>https://cep.is/01001000</c> is what you access on the object. The +/// mapping from wire name to property is: +/// </para> +/// <list type="table"> +/// <listheader><term>wire</term><description>property</description></listheader> +/// <item><term><c>cep</c></term><description><see cref="Cep"/></description></item> +/// <item><term><c>logradouro</c></term><description><see cref="Logradouro"/></description></item> +/// <item><term><c>complemento</c></term><description><see cref="Complemento"/></description></item> +/// <item><term><c>unidade</c></term><description><see cref="Unidade"/></description></item> +/// <item><term><c>bairro</c></term><description><see cref="Bairro"/></description></item> +/// <item><term><c>localidade</c></term><description><see cref="Localidade"/></description></item> +/// <item><term><c>uf</c></term><description><see cref="UF"/></description></item> +/// <item><term><c>estado</c></term><description><see cref="Estado"/></description></item> +/// <item><term><c>regiao</c></term><description><see cref="Regiao"/></description></item> +/// <item><term><c>ibge</c></term><description><see cref="Ibge"/></description></item> +/// <item><term><c>gia</c></term><description><see cref="Gia"/></description></item> +/// <item><term><c>ddd</c></term><description><see cref="Ddd"/></description></item> +/// <item><term><c>siafi</c></term><description><see cref="Siafi"/></description></item> +/// <item><term><c>geo</c></term><description><see cref="Geo"/></description></item> +/// </list> +/// <para> +/// Fields the database does not have for that CEP come back as empty strings, +/// never <see langword="null"/> — that is what the API returns. +/// </para> +/// <para> +/// <see cref="Geo"/> is the exception: it is <see langword="null"/> when there +/// is no coordinate. There the difference is real, because the API <b>omits</b> +/// the whole field in that case (about 43% of CEPs) rather than sending null. +/// It is also always <see langword="null"/> for an address that came from +/// <see cref="CepisClient.LookupManyAsync(IEnumerable{string}, CancellationToken)"/>, +/// which carries no coordinate at all. +/// </para> +/// <para> +/// <see cref="Extra"/> holds any field the API has grown that this version of +/// the library does not model, so an outdated client loses nothing. It takes no +/// part in equality, hashing or <see cref="object.ToString"/>: two addresses +/// describing the same place are equal even if one of them was built by hand. +/// </para> +/// </remarks> +public sealed record Address +{ + /// <summary>The eight digits, always unmasked. Wire name <c>cep</c>.</summary> + [JsonPropertyName("cep")] + public string Cep { get; init; } = ""; + + /// <summary>The street. Wire name <c>logradouro</c>.</summary> + [JsonPropertyName("logradouro")] + public string Logradouro { get; init; } = ""; + + /// <summary> + /// The qualifier on the street, such as a range of numbers. Wire name + /// <c>complemento</c>. + /// </summary> + [JsonPropertyName("complemento")] + public string Complemento { get; init; } = ""; + + /// <summary> + /// The named unit, for a CEP that belongs to one. Wire name <c>unidade</c>. + /// </summary> + [JsonPropertyName("unidade")] + public string Unidade { get; init; } = ""; + + /// <summary>The neighbourhood. Wire name <c>bairro</c>.</summary> + [JsonPropertyName("bairro")] + public string Bairro { get; init; } = ""; + + /// <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 GIA code, São Paulo only. Wire name <c>gia</c>.</summary> + [JsonPropertyName("gia")] + public string Gia { get; init; } = ""; + + /// <summary>The telephone area code. Wire name <c>ddd</c>.</summary> + [JsonPropertyName("ddd")] + public string Ddd { get; init; } = ""; + + /// <summary>The SIAFI municipality code. Wire name <c>siafi</c>.</summary> + [JsonPropertyName("siafi")] + public string Siafi { get; init; } = ""; + + /// <summary> + /// The coordinate, or <see langword="null"/> when there is none. Wire name + /// <c>geo</c>. + /// </summary> + /// <remarks> + /// A malformed <c>geo</c> block yields <see langword="null"/> rather than + /// failing the whole response: the rest of the address is still useful. + /// </remarks> + [JsonPropertyName("geo")] + [JsonConverter(typeof(GeoConverter))] + public Geo? Geo { get; init; } + + /// <summary> + /// Fields the API returned that this version of the library does not model. + /// </summary> + /// <remarks> + /// A new field in the API therefore cannot break this client, and cannot be + /// silently dropped either. Empty for an address built by hand. + /// </remarks> + [JsonExtensionData] + public Dictionary<string, JsonElement>? Extra { get; init; } + + /// <summary>The CEP with its mask, <c>01001-000</c>.</summary> + /// <remarks> + /// The API always returns it unmasked; formatting is presentation, and it + /// lives here so it does not become a repeated interpolation in every + /// project that uses this library. A CEP that is not eight characters long + /// is returned unchanged. + /// </remarks> + [JsonIgnore] + public string FormattedCep => Cep.Length == 8 ? $"{Cep[..5]}-{Cep[5..]}" : Cep; + + /// <summary>Compare every field but <see cref="Extra"/>.</summary> + /// <param name="other">the address to compare with</param> + /// <returns><see langword="true"/> when both describe the same place</returns> + public bool Equals(Address? other) => + other is not null + && Cep == other.Cep + && Logradouro == other.Logradouro + && Complemento == other.Complemento + && Unidade == other.Unidade + && Bairro == other.Bairro + && Localidade == other.Localidade + && UF == other.UF + && Estado == other.Estado + && Regiao == other.Regiao + && Ibge == other.Ibge + && Gia == other.Gia + && Ddd == other.Ddd + && Siafi == other.Siafi + && Geo == other.Geo; + + /// <summary>Hash every field but <see cref="Extra"/>.</summary> + /// <returns>a hash consistent with <see cref="Equals(Address)"/></returns> + public override int GetHashCode() + { + var hash = default(HashCode); + hash.Add(Cep); + hash.Add(Logradouro); + hash.Add(Complemento); + hash.Add(Unidade); + hash.Add(Bairro); + hash.Add(Localidade); + hash.Add(UF); + hash.Add(Estado); + hash.Add(Regiao); + hash.Add(Ibge); + hash.Add(Gia); + hash.Add(Ddd); + hash.Add(Siafi); + hash.Add(Geo); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Cep = ").Append(Cep) + .Append(", Logradouro = ").Append(Logradouro) + .Append(", Complemento = ").Append(Complemento) + .Append(", Unidade = ").Append(Unidade) + .Append(", Bairro = ").Append(Bairro) + .Append(", Localidade = ").Append(Localidade) + .Append(", UF = ").Append(UF) + .Append(", Estado = ").Append(Estado) + .Append(", Regiao = ").Append(Regiao) + .Append(", Ibge = ").Append(Ibge) + .Append(", Gia = ").Append(Gia) + .Append(", Ddd = ").Append(Ddd) + .Append(", Siafi = ").Append(Siafi) + .Append(", Geo = ").Append(Geo?.ToString() ?? "null"); + return true; + } +} diff --git a/src/Cepis/AssemblyInfo.cs b/src/Cepis/AssemblyInfo.cs new file mode 100644 index 0000000..88c4636 --- /dev/null +++ b/src/Cepis/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cepis.Tests")] diff --git a/src/Cepis/CepNotFoundException.cs b/src/Cepis/CepNotFoundException.cs new file mode 100644 index 0000000..ced7b16 --- /dev/null +++ b/src/Cepis/CepNotFoundException.cs @@ -0,0 +1,27 @@ +namespace Cepis; + +/// <summary> +/// The CEP is well-formed but does not exist in the DNE database. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +public sealed class CepNotFoundException : CepisException +{ + /// <summary>Create the exception with a message.</summary> + /// <param name="message">what went wrong</param> + public CepNotFoundException(string message) + : base(message) + { + } + + /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="innerException">the failure underneath</param> + public CepNotFoundException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Cepis/Cepis.csproj b/src/Cepis/Cepis.csproj new file mode 100644 index 0000000..16ffb75 --- /dev/null +++ b/src/Cepis/Cepis.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <LangVersion>12.0</LangVersion> + <AssemblyName>Cepis</AssemblyName> + <RootNamespace>Cepis</RootNamespace> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <EnableNETAnalyzers>true</EnableNETAnalyzers> + <AnalysisLevel>latest</AnalysisLevel> + </PropertyGroup> + + <PropertyGroup> + <PackageId>Cepis</PackageId> + <Version>0.1.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> + <PackageReadmeFile>README.md</PackageReadmeFile> + <PackageProjectUrl>https://cep.is</PackageProjectUrl> + <PackageTags>cep;correios;brasil;address;geocoding</PackageTags> + <IncludeSymbols>true</IncludeSymbols> + <SymbolPackageFormat>snupkg</SymbolPackageFormat> + </PropertyGroup> + + <ItemGroup> + <None Include="../../README.md" Pack="true" PackagePath="/" /> + </ItemGroup> + +</Project> diff --git a/src/Cepis/CepisClient.cs b/src/Cepis/CepisClient.cs new file mode 100644 index 0000000..72637c0 --- /dev/null +++ b/src/Cepis/CepisClient.cs @@ -0,0 +1,714 @@ +using System.Collections.Frozen; +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace Cepis; + +/// <summary> +/// Client for the public CEP API at <see href="https://cep.is">cep.is</see>. +/// </summary> +/// <remarks> +/// <para> +/// Build one and reuse it. The configuration is immutable, no method keeps +/// state between calls, and the single underlying <see cref="HttpClient"/> +/// pools connections — sharing one instance across threads is both safe and +/// faster than creating them. A client per request is the classic way to run a +/// .NET process out of sockets. +/// </para> +/// <example> +/// <code> +/// using var client = new CepisClient(new CepisClientOptions +/// { +/// BaseUrl = "http://localhost:8080", +/// }); +/// +/// Address address = await client.LookupAsync("01001-000"); +/// </code> +/// </example> +/// <para> +/// For a script, <see cref="Default"/> is a shared client with the default +/// configuration, built on first use. +/// </para> +/// <para> +/// Retries cover 429, the transient 5xx and network failures, with exponential +/// backoff and full jitter. 400 and 404 are never retried: both are definitive +/// answers about the request, so retrying returns the same thing more slowly +/// while burning someone else's quota. +/// </para> +/// <para> +/// A note on names: the API surface is English, but the <b>data fields keep the +/// API's own Portuguese names</b> (<see cref="Address.Logradouro"/>, +/// <see cref="Address.Bairro"/>, <see cref="Address.Localidade"/>), with only +/// the casing adapted. What you read in the JSON is what you access on the +/// object. +/// </para> +/// </remarks> +public sealed class CepisClient : IDisposable +{ + /// <summary>Where the public API lives.</summary> + public const string DefaultUrl = "https://cep.is"; + + /// <summary>How many CEPs the batch endpoint accepts in one request.</summary> + public const int MaxBatch = 500; + + /// <summary>The version of this library.</summary> + public const string Version = "0.1.0"; + + private const string AcceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1"; + + private static readonly FrozenSet<int> RetriableStatus = + new[] { 429, 500, 502, 503, 504 }.ToFrozenSet(); + + private static readonly FrozenSet<int> OkOnly = new[] { 200 }.ToFrozenSet(); + + private static readonly FrozenSet<int> HealthStatus = new[] { 200, 503 }.ToFrozenSet(); + + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static readonly Lazy<CepisClient> SharedDefault = new(() => new CepisClient()); + + private readonly HttpClient _http; + private readonly bool _ownsHttp; + private readonly string _baseUrl; + private readonly CepisClientOptions _options; + + /// <summary>Build a client with the default configuration.</summary> + public CepisClient() + : this(new CepisClientOptions()) + { + } + + /// <summary>Build a client with its own <see cref="HttpClient"/>.</summary> + /// <param name="options">the configuration</param> + /// <exception cref="ArgumentNullException"><paramref name="options"/> is null</exception> + /// <exception cref="ArgumentException">a setting is out of range</exception> + public CepisClient(CepisClientOptions options) + : this(NewHttpClient(), options, ownsHttp: true) + { + } + + /// <summary> + /// Build a client over an <see cref="HttpClient"/> the caller owns. + /// </summary> + /// <remarks> + /// For applications that resolve their <see cref="HttpClient"/> from + /// <c>IHttpClientFactory</c> or a container. The instance is neither + /// reconfigured nor disposed by this client, so its own + /// <see cref="HttpClient.Timeout"/> still applies on top of + /// <see cref="CepisClientOptions.Timeout"/> — whichever is shorter wins. + /// </remarks> + /// <param name="httpClient">the transport to use</param> + /// <param name="options">the configuration, or null for the defaults</param> + /// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is null</exception> + /// <exception cref="ArgumentException">a setting is out of range</exception> + public CepisClient(HttpClient httpClient, CepisClientOptions? options = null) + : this(httpClient, options ?? new CepisClientOptions(), ownsHttp: false) + { + } + + private CepisClient(HttpClient httpClient, CepisClientOptions options, bool ownsHttp) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(options); + Validate(options); + + _http = httpClient; + _ownsHttp = ownsHttp; + _options = options; + _baseUrl = options.BaseUrl.Trim().TrimEnd('/'); + UserAgent = options.UserAgent ?? $"cepis-csharp/{Version} (+https://cep.is)"; + } + + /// <summary> + /// A shared client with the default configuration, built on first use. + /// </summary> + /// <remarks> + /// Convenient for scripts and one-off lookups. It applies process-wide, + /// including to third-party libraries that also use this package, so do not + /// dispose it; anyone needing different settings should build their own. + /// </remarks> + public static CepisClient Default => SharedDefault.Value; + + /// <summary>Where the API lives, without a trailing slash.</summary> + public string BaseUrl => _baseUrl; + + /// <summary>The configuration this client was built with.</summary> + public CepisClientOptions Options => _options; + + /// <summary>The <c>User-Agent</c> this client sends.</summary> + public string UserAgent { get; } + + /// <summary> + /// Return the CEP's eight digits, accepting the mask. + /// </summary> + /// <remarks> + /// <c>"01001-000"</c>, <c>"01001000"</c> and <c>" 01001-000 "</c> all become + /// <c>"01001000"</c>. This runs <b>before</b> any request: a seven-digit CEP + /// is the caller's bug, and spending a round trip for the server to say so + /// wastes both sides' time. + /// </remarks> + /// <param name="value">the CEP, masked or not</param> + /// <returns>the eight digits</returns> + /// <exception cref="InvalidCepException">unless exactly eight digits remain</exception> + public static string NormalizeCep(string value) + { + if (value is null) + { + throw new InvalidCepException("the CEP must not be null"); + } + + var cleaned = value.Trim().Replace("-", "", StringComparison.Ordinal) + .Replace(".", "", StringComparison.Ordinal) + .Replace(" ", "", StringComparison.Ordinal); + + if (!IsEightDigits(cleaned)) + { + throw new InvalidCepException($"the CEP must be 8 digits, got \"{value}\""); + } + + return cleaned; + } + + /// <summary> + /// Look up a single CEP. + /// </summary> + /// <remarks> + /// 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"/>. + /// </remarks> + /// <param name="cep">the CEP, masked or not</param> + /// <param name="cancellationToken">cancels the call, waits included</param> + /// <returns>the address</returns> + /// <exception cref="InvalidCepException"> + /// 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="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> + /// <exception cref="InvalidResponseException">the body cannot be read</exception> + public async Task<Address> LookupAsync(string cep, CancellationToken cancellationToken = default) + { + var number = NormalizeCep(cep); + var reply = await RequestAsync($"/{number}", OkOnly, cancellationToken).ConfigureAwait(false); + + if (reply.Status == 404) + { + throw new CepNotFoundException($"CEP {number} does not exist in the DNE database"); + } + + if (reply.Status == 400) + { + throw new InvalidCepException( + ErrorMessage(reply.Body, $"CEP {number} was rejected by the API")); + } + + return Deserialize<Address>(reply.Body); + } + + /// <summary> + /// Look up many CEPs in one batch. + /// </summary> + /// <remarks> + /// <para> + /// The returned list has <b>the same length and the same order</b> as the + /// input. A CEP that does not exist becomes <see langword="null"/> at its + /// position — it does not vanish from the list, and it does not throw. That + /// is the right difference from + /// <see cref="LookupAsync(string, CancellationToken)"/>: in a batch of a + /// thousand, one bad CEP must not cost the other nine hundred and + /// ninety-nine. + /// </para> + /// <para> + /// A malformed CEP also becomes <see langword="null"/>, for the same reason. + /// If you need to know <i>which</i> one was malformed, validate first with + /// <see cref="NormalizeCep(string)"/>. + /// </para> + /// <para> + /// Input longer than <see cref="MaxBatch"/> is chunked into that many CEPs + /// per request automatically. + /// </para> + /// <para> + /// <b>Batches carry no coordinate.</b> The endpoint behind them is CSV, and + /// the CSV has no <c>geo</c> column. Every address returned here has + /// <see cref="Address.Geo"/> <see langword="null"/>, including for CEPs that + /// do have one. Need the coordinate? Use + /// <see cref="LookupAsync(string, CancellationToken)"/>, one at a time. + /// </para> + /// </remarks> + /// <param name="ceps">the CEPs, masked or not</param> + /// <param name="cancellationToken">cancels the call, waits included</param> + /// <returns> + /// one entry per input CEP, in order, <see langword="null"/> where there is + /// no address + /// </returns> + /// <exception cref="ArgumentNullException"><paramref name="ceps"/> is null</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">a request never completed</exception> + public async Task<IReadOnlyList<Address?>> LookupManyAsync( + IEnumerable<string> ceps, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ceps); + + var wanted = ceps as IReadOnlyList<string> ?? [.. ceps]; + if (wanted.Count == 0) + { + return []; + } + + var result = new List<Address?>(wanted.Count); + for (var start = 0; start < wanted.Count; start += MaxBatch) + { + var end = Math.Min(start + MaxBatch, wanted.Count); + var chunk = new List<string>(end - start); + for (var i = start; i < end; i++) + { + chunk.Add(wanted[i]); + } + + result.AddRange(await LookupChunkAsync(chunk, cancellationToken).ConfigureAwait(false)); + } + + return result; + } + + /// <summary> + /// Query <c>GET /health</c>: does the API answer, and with which database. + /// </summary> + /// <remarks> + /// An API reporting itself degraded does not throw — that arrives in + /// <see cref="Health.Status"/>, and checking health is exactly when you want + /// the diagnosis rather than an exception. Network failures still throw. + /// </remarks> + /// <param name="cancellationToken">cancels the call, waits included</param> + /// <returns>the API's health</returns> + /// <exception cref="NetworkException">the request never completed</exception> + /// <exception cref="InvalidResponseException">the body cannot be read</exception> + public async Task<Health> HealthAsync(CancellationToken cancellationToken = default) + { + var reply = await RequestAsync("/health", HealthStatus, cancellationToken).ConfigureAwait(false); + return Deserialize<Health>(reply.Body); + } + + /// <summary> + /// Release the <see cref="HttpClient"/> this client created for itself. + /// </summary> + /// <remarks> + /// An <see cref="HttpClient"/> handed in by the caller is left alone. + /// </remarks> + public void Dispose() + { + if (_ownsHttp) + { + _http.Dispose(); + } + } + + /// <summary> + /// Parse the <c>Retry-After</c> header in both RFC 9110 forms. + /// </summary> + /// <remarks> + /// It may arrive as a number of seconds (<c>"120"</c>) or as an HTTP date + /// (<c>"Wed, 21 Oct 2026 07:28:00 GMT"</c>). Handling only the first is the + /// common bug, and it makes a client ignore exactly the wait request that + /// matters most. + /// </remarks> + /// <param name="value">the header value, if any</param> + /// <returns>the wait, or null when there is nothing usable</returns> + internal static TimeSpan? ParseRetryAfter(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var header = value.Trim(); + if (double.TryParse(header, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) + { + return TimeSpan.FromSeconds(Math.Max(0, seconds)); + } + + const DateTimeStyles styles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; + if (!DateTimeOffset.TryParseExact(header, "r", CultureInfo.InvariantCulture, styles, out var target) + && !DateTimeOffset.TryParse(header, CultureInfo.InvariantCulture, styles, out target)) + { + return null; + } + + var wait = target - DateTimeOffset.UtcNow; + return wait < TimeSpan.Zero ? TimeSpan.Zero : wait; + } + + private static HttpClient NewHttpClient() => + new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + AutomaticDecompression = System.Net.DecompressionMethods.All, + }) + { + Timeout = System.Threading.Timeout.InfiniteTimeSpan, + }; + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.General) + { + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + ReadCommentHandling = JsonCommentHandling.Disallow, + AllowTrailingCommas = false, + }; + options.Converters.Add(new LenientStringConverter()); + return options; + } + + private static void Validate(CepisClientOptions options) + { + if (string.IsNullOrWhiteSpace(options.BaseUrl)) + { + throw new ArgumentException("BaseUrl must not be empty", nameof(options)); + } + + if (options.MaxAttempts < 1) + { + throw new ArgumentException("MaxAttempts must be >= 1", nameof(options)); + } + + if (options.Timeout <= TimeSpan.Zero) + { + throw new ArgumentException("Timeout must be > 0", nameof(options)); + } + + if (options.BackoffBase < TimeSpan.Zero) + { + throw new ArgumentException("BackoffBase must be >= 0", nameof(options)); + } + + if (options.BackoffMax < TimeSpan.Zero) + { + throw new ArgumentException("BackoffMax must be >= 0", nameof(options)); + } + + if (options.TotalTimeout is { } total && total <= TimeSpan.Zero) + { + throw new ArgumentException("TotalTimeout must be > 0 when set", nameof(options)); + } + } + + private static bool IsEightDigits(string value) + { + if (value.Length != 8) + { + return false; + } + + foreach (var c in value) + { + if (c is < '0' or > '9') + { + return false; + } + } + + return true; + } + + private static T Deserialize<T>(byte[] body) + where T : class + { + T? value; + try + { + value = JsonSerializer.Deserialize<T>(body, JsonOptions); + } + catch (JsonException unreadable) + { + throw new InvalidResponseException( + "the response is not valid JSON — something between you and the API is " + + $"answering instead of it: {Preview(body)}", + unreadable); + } + + return value ?? throw new InvalidResponseException( + $"expected a JSON object, got {Preview(body)}"); + } + + private static string ErrorMessage(byte[] body, string fallback) + { + try + { + using var document = JsonDocument.Parse(new ReadOnlyMemory<byte>(body)); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("mensagem", out var message) + && message.ValueKind == JsonValueKind.String + && message.GetString() is { Length: > 0 } text) + { + return text; + } + } + catch (JsonException) + { + return fallback; + } + + return fallback; + } + + private static string Preview(byte[] body) => + Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 120)); + + /// <summary> + /// Turn one CSV record into an address, or null when there is none. + /// </summary> + /// <remarks> + /// The CSV column names are the JSON field names, so the record is routed + /// through the same mapping as a native lookup rather than a second, + /// drifting copy of it. A record the API filled only the <c>cep</c> column + /// of means "not found". + /// </remarks> + /// <param name="row">the record, by column name</param> + /// <returns>the address, or null</returns> + private static Address? AddressFromRow(Dictionary<string, string> row) + { + if (!row.TryGetValue("localidade", out var localidade) || string.IsNullOrWhiteSpace(localidade)) + { + return null; + } + + return JsonSerializer.SerializeToElement(row).Deserialize<Address>(JsonOptions); + } + + private static string? FirstHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null; + + /// <summary> + /// One chunk of at most <see cref="MaxBatch"/> CEPs, in a single request. + /// </summary> + /// <remarks> + /// The API guarantees one record per requested CEP. Should that ever fail to + /// hold — a proxy truncating, a cut response — the result is padded to the + /// input length rather than returned short, because a shifted list silently + /// pairs the wrong address with the wrong CEP. + /// </remarks> + /// <param name="ceps">the chunk</param> + /// <param name="cancellationToken">cancels the call</param> + /// <returns>one entry per CEP in the chunk, in order</returns> + private async Task<List<Address?>> LookupChunkAsync( + List<string> ceps, + CancellationToken cancellationToken) + { + var query = "/csv?ceps=" + Uri.EscapeDataString(string.Join(',', ceps)); + var reply = await RequestAsync(query, OkOnly, cancellationToken).ConfigureAwait(false); + + var rows = new List<Address?>(ceps.Count); + foreach (var row in Csv.Parse(Encoding.UTF8.GetString(reply.Body))) + { + rows.Add(AddressFromRow(row)); + } + + while (rows.Count < ceps.Count) + { + rows.Add(null); + } + + return rows.Count > ceps.Count ? rows.GetRange(0, ceps.Count) : rows; + } + + /// <summary> + /// Perform the GET, with retries. + /// </summary> + /// <remarks> + /// <para> + /// <i>Definitive</i> error statuses (400, 404) are returned normally, for + /// the caller to turn into the right exception — only transient failures + /// become exceptions here, and only once the attempts are exhausted. + /// </para> + /// <para> + /// <see cref="CepisClientOptions.TotalTimeout"/> arms its own token, which + /// cuts an attempt that is still in flight and any wait between attempts, + /// so the whole call really is bounded. That token is also how an abort is + /// told apart from a transport failure — asking it, rather than comparing a + /// clock against the budget, is what keeps the two from racing. The abort + /// does not overwrite the diagnosis: a caller whose budget ran out while + /// the API was answering 503 is told about the 503, not about a + /// cancellation it did not ask for. + /// </para> + /// </remarks> + /// <param name="path">the path, query included</param> + /// <param name="acceptedStatus">statuses to hand back rather than retry</param> + /// <param name="cancellationToken">cancels the call</param> + /// <returns>the status and the body</returns> + private async Task<Reply> RequestAsync( + string path, + FrozenSet<int> acceptedStatus, + CancellationToken cancellationToken) + { + var uri = _baseUrl + path; + + using var deadline = _options.TotalTimeout is null + ? null + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline?.CancelAfter(_options.TotalTimeout!.Value); + var callToken = deadline?.Token ?? cancellationToken; + + var attempts = 0; + var lastStatus = 0; + TimeSpan? lastRetryAfter = null; + Exception? lastFailure = null; + + for (var attempt = 1; attempt <= _options.MaxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (deadline is { IsCancellationRequested: true }) + { + break; + } + + attempts = attempt; + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", AcceptHeader); + + using var attemptCancellation = CancellationTokenSource.CreateLinkedTokenSource(callToken); + attemptCancellation.CancelAfter(_options.Timeout); + + try + { + using var response = await _http + .SendAsync(request, HttpCompletionOption.ResponseContentRead, attemptCancellation.Token) + .ConfigureAwait(false); + + var status = (int)response.StatusCode; + if (acceptedStatus.Contains(status) || !RetriableStatus.Contains(status)) + { + var body = await response.Content + .ReadAsByteArrayAsync(attemptCancellation.Token) + .ConfigureAwait(false); + return new Reply(status, body); + } + + lastStatus = status; + lastRetryAfter = ParseRetryAfter(FirstHeader(response, "Retry-After")); + lastFailure = null; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception unreachable) when (unreachable is HttpRequestException + or OperationCanceledException + or SocketException + or IOException) + { + if (deadline is { IsCancellationRequested: true }) + { + if (lastStatus == 0) + { + lastFailure = unreachable; + } + + break; + } + + lastFailure = unreachable; + lastStatus = 0; + lastRetryAfter = null; + } + + if (attempt == _options.MaxAttempts) + { + break; + } + + if (!await WaitBeforeRetryAsync(attempt, lastRetryAfter, callToken, cancellationToken) + .ConfigureAwait(false)) + { + break; + } + } + + var suffix = $" (after {attempts} attempt(s))"; + if (lastStatus == 429) + { + throw new RateLimitedException($"rate limit reached at {uri}{suffix}", lastRetryAfter); + } + + if (lastStatus != 0) + { + throw new ServerErrorException( + $"the API answered {lastStatus} at {uri}{suffix}", lastStatus); + } + + throw new NetworkException( + $"could not reach {uri}{suffix}: {lastFailure?.Message}", lastFailure); + } + + /// <summary> + /// Wait out the backoff, reporting whether there is still budget to retry. + /// </summary> + /// <remarks> + /// The wait is cut short by <paramref name="callToken"/>, so a + /// <see cref="CepisClientOptions.TotalTimeout"/> that expires mid-wait ends + /// the call rather than being overrun. A cancellation the caller asked for + /// propagates instead. + /// </remarks> + /// <param name="attempt">which attempt just failed, from one</param> + /// <param name="retryAfter">what the server asked for, if anything</param> + /// <param name="callToken">the deadline, or the caller's token</param> + /// <param name="cancellationToken">the caller's token alone</param> + /// <returns>false when the budget is spent</returns> + private async Task<bool> WaitBeforeRetryAsync( + int attempt, + TimeSpan? retryAfter, + CancellationToken callToken, + CancellationToken cancellationToken) + { + var wait = Backoff(attempt, retryAfter); + if (wait <= TimeSpan.Zero) + { + return !callToken.IsCancellationRequested; + } + + try + { + await Task.Delay(wait, callToken).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + } + + /// <summary> + /// How long to wait before the next attempt. + /// </summary> + /// <remarks> + /// The server's <c>Retry-After</c> wins over the local calculation when + /// present: it knows when it will be back, we are guessing. It still goes + /// through the <see cref="CepisClientOptions.BackoffMax"/> cap. Otherwise + /// the wait is drawn from <c>[0, ceiling]</c> rather than being the ceiling. + /// </remarks> + /// <param name="attempt">which attempt just failed, from one</param> + /// <param name="retryAfter">what the server asked for, if anything</param> + /// <returns>the wait</returns> + internal TimeSpan Backoff(int attempt, TimeSpan? retryAfter) + { + var ceiling = _options.BackoffMax.TotalMilliseconds; + if (retryAfter is { } asked) + { + return TimeSpan.FromMilliseconds(Math.Min(asked.TotalMilliseconds, ceiling)); + } + + var exponential = _options.BackoffBase.TotalMilliseconds * Math.Pow(2, Math.Min(attempt - 1, 32)); + return TimeSpan.FromMilliseconds(Random.Shared.NextDouble() * Math.Min(exponential, ceiling)); + } + + private readonly record struct Reply(int Status, byte[] Body); +} diff --git a/src/Cepis/CepisClientOptions.cs b/src/Cepis/CepisClientOptions.cs new file mode 100644 index 0000000..ea9234d --- /dev/null +++ b/src/Cepis/CepisClientOptions.cs @@ -0,0 +1,92 @@ +namespace Cepis; + +/// <summary> +/// How a <see cref="CepisClient"/> behaves. Every setting has a default; the +/// one that usually changes between environments is <see cref="BaseUrl"/>. +/// </summary> +/// <example> +/// <code> +/// using var client = new CepisClient(new CepisClientOptions +/// { +/// BaseUrl = "http://localhost:8080", +/// TotalTimeout = TimeSpan.FromSeconds(15), +/// }); +/// </code> +/// </example> +public sealed record CepisClientOptions +{ + /// <summary> + /// Where the API lives. Defaults to <see cref="CepisClient.DefaultUrl"/>. + /// </summary> + /// <remarks> + /// Point it at a local instance or at a test server. A trailing slash is + /// stripped when the client is built, so it cannot produce doubled + /// separators that some proxies normalise and others answer 404 to. + /// </remarks> + public string BaseUrl { get; init; } = CepisClient.DefaultUrl; + + /// <summary> + /// How long a single attempt may take, connection included. Defaults to ten + /// seconds. + /// </summary> + /// <remarks> + /// It is not a cap on the whole call: with three attempts and the waits + /// between them, the worst case is roughly three times this plus the + /// backoffs. <see cref="TotalTimeout"/> is the cap on the whole call. + /// </remarks> + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10); + + /// <summary> + /// How many attempts in total, counting the first. Defaults to three. + /// </summary> + /// <remarks> + /// <c>1</c> disables retrying. The default of <c>3</c> covers the common + /// case, one transient failure, without turning a real outage into a long + /// wait. + /// </remarks> + public int MaxAttempts { get; init; } = 3; + + /// <summary> + /// The base of the exponential wait between attempts. Defaults to 500 + /// milliseconds. + /// </summary> + /// <remarks> + /// The actual wait is drawn from <c>[0, base * 2^(n-1)]</c> — full jitter. + /// The randomness is not decoration: without it, N clients that fail + /// together come back together and reproduce the spike that took the + /// service down. + /// </remarks> + public TimeSpan BackoffBase { get; init; } = TimeSpan.FromMilliseconds(500); + + /// <summary> + /// The cap on each individual wait. Defaults to eight seconds. + /// </summary> + /// <remarks> + /// It applies to a server-sent <c>Retry-After</c> too, or a + /// <c>Retry-After: 3600</c> from a misconfigured proxy would park the + /// caller for an hour. + /// </remarks> + public TimeSpan BackoffMax { get; init; } = TimeSpan.FromSeconds(8); + + /// <summary> + /// The cap on the whole call, attempts and waits included. Unset by + /// default. + /// </summary> + /// <remarks> + /// <see langword="null"/> leaves <see cref="MaxAttempts"/> as the only + /// bound. Set it in code serving a web request: without it the worst case + /// is <see cref="MaxAttempts"/> times <see cref="Timeout"/> plus the sum of + /// the backoffs. + /// </remarks> + public TimeSpan? TotalTimeout { get; init; } + + /// <summary> + /// Overrides the <c>User-Agent</c>. <see langword="null"/> uses the + /// default. + /// </summary> + /// <remarks> + /// The default identifies the library and its version, which is what lets + /// the server diagnose an old client misbehaving. + /// </remarks> + public string? UserAgent { get; init; } +} diff --git a/src/Cepis/CepisException.cs b/src/Cepis/CepisException.cs new file mode 100644 index 0000000..50af442 --- /dev/null +++ b/src/Cepis/CepisException.cs @@ -0,0 +1,39 @@ +namespace Cepis; + +/// <summary> +/// The base of every error this library raises. Catch it to handle any failure. +/// </summary> +/// <remarks> +/// <para> +/// Below this type the split is by <b>the decision the caller has to make</b>, +/// not by the HTTP status that caused it. +/// <see cref="InvalidCepException"/> and <see cref="CepNotFoundException"/> are +/// problems with the <i>request</i>: retrying is pointless, the same CEP will +/// fail again. <see cref="NetworkException"/>, <see cref="ServerErrorException"/> +/// and <see cref="RateLimitedException"/> are problems with <i>transport</i>: +/// retrying may help, and the client already retried on its own before throwing +/// any of them. +/// </para> +/// <para> +/// C# allows one base class, so these are not also <see cref="ArgumentException"/> +/// or <see cref="KeyNotFoundException"/>: one base type that catches every +/// failure is worth more. +/// </para> +/// </remarks> +public abstract class CepisException : Exception +{ + /// <summary>Create the exception with a message.</summary> + /// <param name="message">what went wrong</param> + protected CepisException(string message) + : base(message) + { + } + + /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="innerException">the failure underneath, if any</param> + protected CepisException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Cepis/Csv.cs b/src/Cepis/Csv.cs new file mode 100644 index 0000000..ca11558 --- /dev/null +++ b/src/Cepis/Csv.cs @@ -0,0 +1,122 @@ +using System.Text; + +namespace Cepis; + +/// <summary> +/// An RFC 4180 reader for the batch endpoint. +/// </summary> +/// <remarks> +/// Values may contain commas, quotes and newlines, so they arrive +/// double-quoted with <c>""</c> escaping; splitting on commas would corrupt +/// exactly the addresses that need care. A leading byte order mark is dropped, +/// and CR, LF and CRLF all end a record. The BCL ships no CSV reader, and the +/// shape this endpoint returns is small enough not to justify a dependency. +/// </remarks> +internal static class Csv +{ + /// <summary> + /// Parse a CSV document whose first record is the header. + /// </summary> + /// <remarks> + /// Each record becomes a dictionary from column name to value. A record + /// with fewer fields than the header has the missing ones filled with + /// <c>""</c>; extra fields are dropped. + /// </remarks> + /// <param name="text">the document</param> + /// <returns>one dictionary per data record, in order</returns> + internal static List<Dictionary<string, string>> Parse(string text) + { + var records = Split(text); + var rows = new List<Dictionary<string, string>>(); + if (records.Count == 0) + { + return rows; + } + + var header = records[0]; + for (var r = 1; r < records.Count; r++) + { + var record = records[r]; + var row = new Dictionary<string, string>(header.Count, StringComparer.Ordinal); + for (var i = 0; i < header.Count; i++) + { + row[header[i]] = i < record.Count ? record[i] : ""; + } + + rows.Add(row); + } + + return rows; + } + + private static List<List<string>> Split(string text) + { + var records = new List<List<string>>(); + var record = new List<string>(); + var field = new StringBuilder(); + var quoted = false; + var pending = false; + + var i = text.Length > 0 && text[0] == '\uFEFF' ? 1 : 0; + for (; i < text.Length; i++) + { + var c = text[i]; + if (quoted) + { + if (c != '"') + { + field.Append(c); + } + else if (i + 1 < text.Length && text[i + 1] == '"') + { + field.Append('"'); + i++; + } + else + { + quoted = false; + } + + continue; + } + + switch (c) + { + case '"': + quoted = true; + pending = true; + break; + case ',': + record.Add(field.ToString()); + field.Clear(); + pending = true; + break; + case '\r': + case '\n': + if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') + { + i++; + } + + record.Add(field.ToString()); + field.Clear(); + records.Add(record); + record = []; + pending = false; + break; + default: + field.Append(c); + pending = true; + break; + } + } + + if (pending || field.Length > 0) + { + record.Add(field.ToString()); + records.Add(record); + } + + return records; + } +} diff --git a/src/Cepis/Geo.cs b/src/Cepis/Geo.cs new file mode 100644 index 0000000..75dfa8b --- /dev/null +++ b/src/Cepis/Geo.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// The CEP's coordinate, when the database has one. +/// </summary> +/// <remarks> +/// <para> +/// The point is the <b>median</b> of the coordinates of that CEP's addresses, +/// not the mean: one badly geocoded address 40 km away shifts a mean and does +/// not move a median. +/// </para> +/// <para> +/// Always check <see cref="Precisao"/> before using <see cref="Lat"/> and +/// <see cref="Lon"/> for distance. <c>"logradouro"</c> means the point is a +/// stretch of street and <see cref="RaioM"/> lands around 100–150 m — good for +/// maps and for distance. <c>"localidade"</c> means the CEP <i>is</i> the whole +/// municipality (a city-wide general CEP) and the point is its centroid, with +/// <see cref="RaioM"/> running from 7 km to 40 km; computing shipping from that +/// is wrong by tens of kilometres. +/// </para> +/// <para> +/// <see cref="RaioM"/> is the radius containing 90% of that CEP's addresses. It +/// exists precisely so that error cannot pass unnoticed. +/// </para> +/// <para> +/// The property names are the API's own, with only the casing adapted: +/// <c>lat</c>, <c>lon</c>, <c>raio_m</c> and <c>precisao</c> on the wire. +/// </para> +/// </remarks> +public sealed record Geo +{ + /// <summary>Latitude in decimal degrees, WGS 84. Wire name <c>lat</c>.</summary> + [JsonPropertyName("lat")] + public double Lat { get; init; } + + /// <summary>Longitude in decimal degrees, WGS 84. Wire name <c>lon</c>.</summary> + [JsonPropertyName("lon")] + public double Lon { get; init; } + + /// <summary> + /// The radius, in metres, containing 90% of that CEP's addresses. Wire name + /// <c>raio_m</c>. + /// </summary> + [JsonPropertyName("raio_m")] + public int RaioM { get; init; } + + /// <summary> + /// What the point describes: <c>"logradouro"</c> for a stretch of street, + /// <c>"localidade"</c> for a municipality centroid. Wire name + /// <c>precisao</c>. + /// </summary> + [JsonPropertyName("precisao")] + public string Precisao { get; init; } = ""; + + /// <summary> + /// <see langword="true"/> when the coordinate points at a street rather than + /// a municipality. + /// </summary> + /// <remarks> + /// Shorthand for <c>Precisao == "logradouro"</c>, which is the check to make + /// before using the point to measure distance. + /// </remarks> + [JsonIgnore] + public bool IsPrecise => Precisao == "logradouro"; +} diff --git a/src/Cepis/GeoConverter.cs b/src/Cepis/GeoConverter.cs new file mode 100644 index 0000000..f5c78de --- /dev/null +++ b/src/Cepis/GeoConverter.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// Reads the <c>geo</c> block, yielding <see langword="null"/> for anything it +/// cannot make sense of. +/// </summary> +/// <remarks> +/// A malformed coordinate is not worth losing the whole address over: the rest +/// of the response is still useful, and an address with no coordinate is a +/// shape callers already handle, since about 43% of CEPs have none. +/// </remarks> +internal sealed class GeoConverter : JsonConverter<Geo?> +{ + public override Geo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + var lat = ReadDouble(root, "lat"); + var lon = ReadDouble(root, "lon"); + var raio = ReadDouble(root, "raio_m"); + var precisao = ReadString(root, "precisao"); + + if (lat is null || lon is null || raio is null || precisao is null) + { + return null; + } + + return new Geo + { + Lat = lat.Value, + Lon = lon.Value, + RaioM = (int)Math.Round(raio.Value), + Precisao = precisao, + }; + } + + public override void Write(Utf8JsonWriter writer, Geo? value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartObject(); + writer.WriteNumber("lat", value.Lat); + writer.WriteNumber("lon", value.Lon); + writer.WriteNumber("raio_m", value.RaioM); + writer.WriteString("precisao", value.Precisao); + writer.WriteEndObject(); + } + + private static double? ReadDouble(JsonElement owner, string name) + { + if (!owner.TryGetProperty(name, out var element)) + { + return null; + } + + return element.ValueKind switch + { + JsonValueKind.Number => element.TryGetDouble(out var number) ? number : null, + JsonValueKind.String => double.TryParse( + element.GetString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var parsed) + ? parsed + : null, + _ => null, + }; + } + + private static string? ReadString(JsonElement owner, string name) => + owner.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String + ? element.GetString() + : null; +} diff --git a/src/Cepis/Health.cs b/src/Cepis/Health.cs new file mode 100644 index 0000000..8101529 --- /dev/null +++ b/src/Cepis/Health.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// The response of <c>GET /health</c>. +/// </summary> +/// <remarks> +/// Good for two things: checking that the API answers, and knowing <b>which +/// version of the database</b> is live. The DNE database changes every two +/// weeks; <see cref="Dne"/> identifies it. +/// </remarks> +public sealed record Health +{ + /// <summary> + /// <c>"ok"</c> when the API reports itself healthy. Wire name + /// <c>status</c>. + /// </summary> + [JsonPropertyName("status")] + public string Status { get; init; } = ""; + + /// <summary> + /// Identifier of the fortnightly DNE database behind the answers. Wire name + /// <c>dne</c>. + /// </summary> + [JsonPropertyName("dne")] + public string Dne { get; init; } = ""; + + /// <summary>How many CEPs that database holds. Wire name <c>ceps</c>.</summary> + [JsonPropertyName("ceps")] + public long Ceps { get; init; } + + /// <summary> + /// How many of those CEPs carry a coordinate. Wire name <c>geo</c>. + /// </summary> + [JsonPropertyName("geo")] + public long Geo { get; init; } + + /// <summary> + /// When the database was generated, as an ISO 8601 instant. Wire name + /// <c>base_gerada_em</c>. + /// </summary> + [JsonPropertyName("base_gerada_em")] + public string BaseGeradaEm { get; init; } = ""; + + /// <summary> + /// <see langword="true"/> when the API reports itself healthy. + /// </summary> + [JsonIgnore] + public bool Ok => Status == "ok"; +} diff --git a/src/Cepis/InvalidCepException.cs b/src/Cepis/InvalidCepException.cs new file mode 100644 index 0000000..8257df7 --- /dev/null +++ b/src/Cepis/InvalidCepException.cs @@ -0,0 +1,29 @@ +namespace Cepis; + +/// <summary> +/// The CEP is not eight digits. +/// </summary> +/// <remarks> +/// Usually thrown <b>without</b> a request ever being made: the client checks +/// the format before going to the network, because a seven-digit CEP is the +/// caller's bug and spending a round trip for the server to say so wastes both +/// sides' time. It is also thrown when the API answers 400 to a CEP that passed +/// local validation, in which case it carries the API's own message. +/// </remarks> +public sealed class InvalidCepException : CepisException +{ + /// <summary>Create the exception with a message.</summary> + /// <param name="message">what went wrong</param> + public InvalidCepException(string message) + : base(message) + { + } + + /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="innerException">the failure underneath</param> + public InvalidCepException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Cepis/InvalidResponseException.cs b/src/Cepis/InvalidResponseException.cs new file mode 100644 index 0000000..e0be6b5 --- /dev/null +++ b/src/Cepis/InvalidResponseException.cs @@ -0,0 +1,27 @@ +namespace Cepis; + +/// <summary> +/// A success status came back with a body that cannot be parsed. +/// </summary> +/// <remarks> +/// In practice this means something between you and the API is answering +/// instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error +/// page. Useful as a diagnosis: the problem is not in your code. +/// </remarks> +public sealed class InvalidResponseException : CepisException +{ + /// <summary>Create the exception with a message.</summary> + /// <param name="message">what went wrong</param> + public InvalidResponseException(string message) + : base(message) + { + } + + /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="innerException">the parse failure underneath</param> + public InvalidResponseException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Cepis/LenientStringConverter.cs b/src/Cepis/LenientStringConverter.cs new file mode 100644 index 0000000..f76c917 --- /dev/null +++ b/src/Cepis/LenientStringConverter.cs @@ -0,0 +1,57 @@ +using System.Buffers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// Reads any scalar as a string, and a JSON <c>null</c> as the empty string. +/// </summary> +/// <remarks> +/// The API documents every text field as a string and an absent one as +/// <c>""</c>, never null. This converter makes a client that meets a +/// <c>null</c>, or a code sent as a number, degrade to the documented shape +/// instead of throwing: none of those is worth failing a whole lookup over. +/// </remarks> +internal sealed class LenientStringConverter : JsonConverter<string> +{ + public override bool HandleNull => true; + + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.String: + return reader.GetString() ?? ""; + case JsonTokenType.Null: + return ""; + case JsonTokenType.True: + return "true"; + case JsonTokenType.False: + return "false"; + case JsonTokenType.Number: + var raw = reader.HasValueSequence + ? reader.ValueSequence.ToArray() + : reader.ValueSpan.ToArray(); + return Encoding.UTF8.GetString(raw); + default: + throw new JsonException($"expected a string, got {reader.TokenType}"); + } + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WriteStringValue(value); + } + + public override string ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetString() ?? ""; + + public override void WriteAsPropertyName(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WritePropertyName(value); + } +} diff --git a/src/Cepis/NetworkException.cs b/src/Cepis/NetworkException.cs new file mode 100644 index 0000000..cb7ea77 --- /dev/null +++ b/src/Cepis/NetworkException.cs @@ -0,0 +1,27 @@ +namespace Cepis; + +/// <summary> +/// The request never completed: DNS, a refused connection, a timeout, TLS. +/// </summary> +/// <remarks> +/// Thrown only after every configured attempt has failed. A cancellation +/// requested by the caller is not this — that surfaces as +/// <see cref="OperationCanceledException"/>, untouched. +/// </remarks> +public sealed class NetworkException : CepisException +{ + /// <summary>Create the exception with a message.</summary> + /// <param name="message">what went wrong</param> + public NetworkException(string message) + : base(message) + { + } + + /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="innerException">the transport failure underneath</param> + public NetworkException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Cepis/RateLimitedException.cs b/src/Cepis/RateLimitedException.cs new file mode 100644 index 0000000..da1e04c --- /dev/null +++ b/src/Cepis/RateLimitedException.cs @@ -0,0 +1,39 @@ +namespace Cepis; + +/// <summary> +/// The API, or the edge in front of it, answered 429 on every attempt. +/// </summary> +public sealed class RateLimitedException : CepisException +{ + /// <summary>Create the exception with a message and the wait requested.</summary> + /// <param name="message">what went wrong</param> + /// <param name="retryAfter"> + /// what the server asked for, or <see langword="null"/> if it did not say + /// </param> + public RateLimitedException(string message, TimeSpan? retryAfter = null) + : base(message) + { + RetryAfter = retryAfter; + } + + /// <summary>Create the exception with a message, the wait and a cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="retryAfter">what the server asked for, if anything</param> + /// <param name="innerException">the failure underneath</param> + public RateLimitedException(string message, TimeSpan? retryAfter, Exception? innerException) + : base(message, innerException) + { + RetryAfter = retryAfter; + } + + /// <summary> + /// How long the server asked the caller to wait, or <see langword="null"/> + /// if it did not say. + /// </summary> + /// <remarks> + /// The client already honoured this across the attempts it made, capped at + /// <see cref="CepisClientOptions.BackoffMax"/>. It is exposed here for + /// callers who want to reschedule the work. + /// </remarks> + public TimeSpan? RetryAfter { get; } +} diff --git a/src/Cepis/ServerErrorException.cs b/src/Cepis/ServerErrorException.cs new file mode 100644 index 0000000..82cea5e --- /dev/null +++ b/src/Cepis/ServerErrorException.cs @@ -0,0 +1,29 @@ +namespace Cepis; + +/// <summary> +/// The API answered 5xx on every attempt. +/// </summary> +public sealed class ServerErrorException : CepisException +{ + /// <summary>Create the exception with a message and the status seen.</summary> + /// <param name="message">what went wrong</param> + /// <param name="status">the HTTP status of the last attempt</param> + public ServerErrorException(string message, int status) + : base(message) + { + Status = status; + } + + /// <summary>Create the exception with a message, the status and a cause.</summary> + /// <param name="message">what went wrong</param> + /// <param name="status">the HTTP status of the last attempt</param> + /// <param name="innerException">the failure underneath</param> + public ServerErrorException(string message, int status, Exception? innerException) + : base(message, innerException) + { + Status = status; + } + + /// <summary>The HTTP status seen on the last attempt.</summary> + public int Status { get; } +} |