From a2b10fd494fb389e556e40134031eb86e56e11e7 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:42:38 -0300 Subject: Initial import --- .editorconfig | 31 ++ .gitignore | 11 + Cepis.sln | 36 ++ LICENSE | 15 + Makefile | 43 ++ README.md | 239 +++++++++++ src/Cepis/Address.cs | 205 +++++++++ src/Cepis/AssemblyInfo.cs | 3 + src/Cepis/CepNotFoundException.cs | 27 ++ src/Cepis/Cepis.csproj | 33 ++ src/Cepis/CepisClient.cs | 714 ++++++++++++++++++++++++++++++++ src/Cepis/CepisClientOptions.cs | 92 ++++ src/Cepis/CepisException.cs | 39 ++ src/Cepis/Csv.cs | 122 ++++++ src/Cepis/Geo.cs | 67 +++ src/Cepis/GeoConverter.cs | 89 ++++ src/Cepis/Health.cs | 51 +++ src/Cepis/InvalidCepException.cs | 29 ++ src/Cepis/InvalidResponseException.cs | 27 ++ src/Cepis/LenientStringConverter.cs | 57 +++ src/Cepis/NetworkException.cs | 27 ++ src/Cepis/RateLimitedException.cs | 39 ++ src/Cepis/ServerErrorException.cs | 29 ++ tests/Cepis.Tests/BatchTests.cs | 120 ++++++ tests/Cepis.Tests/Cepis.Tests.csproj | 26 ++ tests/Cepis.Tests/ConfigurationTests.cs | 80 ++++ tests/Cepis.Tests/CsvTests.cs | 79 ++++ tests/Cepis.Tests/FakeServer.cs | 168 ++++++++ tests/Cepis.Tests/Fixtures.cs | 69 +++ tests/Cepis.Tests/HealthTests.cs | 46 ++ tests/Cepis.Tests/HttpTestBase.cs | 33 ++ tests/Cepis.Tests/LookupTests.cs | 168 ++++++++ tests/Cepis.Tests/ModelTests.cs | 68 +++ tests/Cepis.Tests/NormalizeTests.cs | 35 ++ tests/Cepis.Tests/RetryTests.cs | 249 +++++++++++ 35 files changed, 3166 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Cepis.sln create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 src/Cepis/Address.cs create mode 100644 src/Cepis/AssemblyInfo.cs create mode 100644 src/Cepis/CepNotFoundException.cs create mode 100644 src/Cepis/Cepis.csproj create mode 100644 src/Cepis/CepisClient.cs create mode 100644 src/Cepis/CepisClientOptions.cs create mode 100644 src/Cepis/CepisException.cs create mode 100644 src/Cepis/Csv.cs create mode 100644 src/Cepis/Geo.cs create mode 100644 src/Cepis/GeoConverter.cs create mode 100644 src/Cepis/Health.cs create mode 100644 src/Cepis/InvalidCepException.cs create mode 100644 src/Cepis/InvalidResponseException.cs create mode 100644 src/Cepis/LenientStringConverter.cs create mode 100644 src/Cepis/NetworkException.cs create mode 100644 src/Cepis/RateLimitedException.cs create mode 100644 src/Cepis/ServerErrorException.cs create mode 100644 tests/Cepis.Tests/BatchTests.cs create mode 100644 tests/Cepis.Tests/Cepis.Tests.csproj create mode 100644 tests/Cepis.Tests/ConfigurationTests.cs create mode 100644 tests/Cepis.Tests/CsvTests.cs create mode 100644 tests/Cepis.Tests/FakeServer.cs create mode 100644 tests/Cepis.Tests/Fixtures.cs create mode 100644 tests/Cepis.Tests/HealthTests.cs create mode 100644 tests/Cepis.Tests/HttpTestBase.cs create mode 100644 tests/Cepis.Tests/LookupTests.cs create mode 100644 tests/Cepis.Tests/ModelTests.cs create mode 100644 tests/Cepis.Tests/NormalizeTests.cs create mode 100644 tests/Cepis.Tests/RetryTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..79e5662 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,31 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,md}] +indent_size = 2 + +[*.{csproj,props,targets}] +indent_size = 2 + +[*.cs] +csharp_using_directive_placement = outside_namespace:warning +csharp_style_namespace_declarations = file_scoped:warning +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_prefer_braces = true:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_readonly_field = true:warning +csharp_style_var_when_type_is_apparent = true:suggestion diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d3db90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +bin/ +obj/ +artifacts/ +*.user +*.suo +.vs/ +.vscode/ +.idea/ +TestResults/ +*.nupkg +*.snupkg diff --git a/Cepis.sln b/Cepis.sln new file mode 100644 index 0000000..243594c --- /dev/null +++ b/Cepis.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EE5A614A-2E37-49CC-81D9-DC456601DB65}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cepis", "src\Cepis\Cepis.csproj", "{F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{3FBB17D5-A9E3-465B-AF9B-CE379BFA1F18}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cepis.Tests", "tests\Cepis.Tests\Cepis.Tests.csproj", "{DECE4541-8244-4116-9999-E844611B1EA3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E}.Release|Any CPU.Build.0 = Release|Any CPU + {DECE4541-8244-4116-9999-E844611B1EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DECE4541-8244-4116-9999-E844611B1EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DECE4541-8244-4116-9999-E844611B1EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DECE4541-8244-4116-9999-E844611B1EA3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F4FEED87-AD8C-4D28-9F12-EFB1071AB11E} = {EE5A614A-2E37-49CC-81D9-DC456601DB65} + {DECE4541-8244-4116-9999-E844611B1EA3} = {3FBB17D5-A9E3-465B-AF9B-CE379BFA1F18} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e9104d --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2bdf654 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +DOTNET ?= dotnet +CONFIGURATION ?= Release +SOLUTION := Cepis.sln +LIBRARY := src/Cepis/Cepis.csproj +ARTIFACTS := artifacts + +.DEFAULT_GOAL := help +.PHONY: help test lint format build pack check clean + +help: + @echo 'Targets:' + @echo ' test run the suite against a local HTTP server' + @echo ' lint dotnet format --verify-no-changes + build -warnaserror (read-only)' + @echo ' format apply dotnet format (writes)' + @echo ' build build the solution in $(CONFIGURATION)' + @echo ' pack the NuGet package into $(ARTIFACTS)' + @echo ' check lint + test + build, in that order' + @echo ' clean remove build output' + @echo + @echo 'Override the configuration with e.g. make build CONFIGURATION=Debug' + +test: + $(DOTNET) test $(SOLUTION) -c $(CONFIGURATION) + +lint: + $(DOTNET) format $(SOLUTION) --verify-no-changes --severity warn + $(DOTNET) build $(SOLUTION) -c $(CONFIGURATION) -warnaserror + +format: + $(DOTNET) format $(SOLUTION) --severity warn + +build: + $(DOTNET) build $(SOLUTION) -c $(CONFIGURATION) + +pack: + $(DOTNET) pack $(LIBRARY) -c $(CONFIGURATION) -o $(ARTIFACTS) + +check: lint test build + +clean: + $(DOTNET) clean $(SOLUTION) -c $(CONFIGURATION) || true + rm -rf $(ARTIFACTS) + find . -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d25149 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# Cepis + +C# client for the [public CEP API at cep.is](https://cep.is). Official Correios +data (DNE), refreshed every two weeks. + +```csharp +using Cepis; + +Address address = await CepisClient.Default.LookupAsync("01001-000"); +Console.WriteLine($"{address.Logradouro} {address.Localidade} {address.UF}"); +// Praça da Sé São Paulo SP +``` + +## Installation + +Requires .NET 8 or newer. There are no runtime dependencies: HTTP comes from +`System.Net.Http`, JSON from `System.Text.Json`, and the CSV reader behind the +batch endpoint is in the library because the BCL has none. + +```sh +git clone https://git.cep.is/csharp.git cepis-csharp +cd cepis-csharp +make pack +``` + +That writes `artifacts/Cepis.0.1.0.nupkg`; add it from a local feed, or +reference `src/Cepis/Cepis.csproj` directly. + +## Usage + +### A single lookup + +`LookupAsync` accepts the CEP masked or unmasked and returns an `Address`: + +```csharp +Address a = await client.LookupAsync("01310-100"); + +a.Logradouro; // "Avenida Paulista" +a.Localidade; // "São Paulo" +a.UF; // "SP" +a.Ibge; // "3550308" +a.Cep; // "01310100" (always unmasked) +a.FormattedCep; // "01310-100" +``` + +The data fields keep the API's own Portuguese names, with only the casing +adapted; a `JsonPropertyName` attribute maps each one to its wire name: + +| wire | property | wire | property | +| --- | --- | --- | --- | +| `cep` | `Cep` | `regiao` | `Regiao` | +| `logradouro` | `Logradouro` | `ibge` | `Ibge` | +| `complemento` | `Complemento` | `gia` | `Gia` | +| `unidade` | `Unidade` | `ddd` | `Ddd` | +| `bairro` | `Bairro` | `siafi` | `Siafi` | +| `localidade` | `Localidade` | `geo.lat` / `geo.lon` | `Geo.Lat` / `Geo.Lon` | +| `uf` | `UF` | `geo.raio_m` | `Geo.RaioM` | +| `estado` | `Estado` | `geo.precisao` | `Geo.Precisao` | + +Fields the database does not have for that CEP come back as empty strings, +never `null`. A field the API grows that this version does not model lands in +`a.Extra`, so nothing is silently dropped. + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `Geo` is `null`: + +```csharp +if (a.Geo is { } geo) +{ + geo.Lat; // -23.567202 + geo.Lon; // -46.649659 + geo.RaioM; // 1084 + geo.Precisao; // "logradouro" +} +``` + +**Check `Precisao` before computing distance.** There are two kinds of point, +and the difference between them is tens of kilometres: + +| `Precisao` | typical radius | what the point is | +| --- | --- | --- | +| `logradouro` | 110–140 m | the stretch of street — good for maps and distance | +| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city | + +`geo.IsPrecise` is the shorthand for `Precisao == "logradouro"`: + +```csharp +decimal cost = a.Geo is { IsPrecise: true } geo + ? ShippingFrom(geo.Lat, geo.Lon) + : ShippingFromMunicipality(a.Ibge); +``` + +### Many CEPs at once + +Do not loop over `LookupAsync`. Use `LookupManyAsync`, which resolves up to 500 +CEPs per request: + +```csharp +string[] ceps = ["01001000", "99999999", "20040020"]; +IReadOnlyList result = await client.LookupManyAsync(ceps); + +for (int i = 0; i < ceps.Length; i++) +{ + Console.WriteLine(result[i] is null + ? $"{ceps[i]}: not found" + : $"{ceps[i]}: {result[i]!.Localidade}"); +} +``` + +The returned list has **the same length and the same order** as the input. A +CEP that does not exist, or is malformed, becomes `null` at its position — it +does not vanish, and it does not throw. + +Inputs longer than 500 are chunked automatically. + +> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV +> has no `geo` column. Every `Address` from `LookupManyAsync` has `Geo == null`, +> including for CEPs that do have one. Need the coordinate? Use `LookupAsync`, +> one at a time. + +### API health + +Useful for two things: checking the API answers, and knowing **which version of +the database** is live: + +```csharp +Health h = await client.HealthAsync(); +h.Ok; // true +h.Dne; // "26071" identifier of the fortnightly database +h.Ceps; // 1596168 +h.BaseGeradaEm; // "2026-07-24T03:48:28Z" +``` + +An API reporting itself degraded does not throw — that arrives in `h.Status`. + +## Configuration + +For anything beyond the defaults, build a `CepisClient` and reuse it. It keeps +no state between calls, holds one pooled `HttpClient`, and is safe to share +across threads: + +```csharp +using var client = new CepisClient(new CepisClientOptions +{ + BaseUrl = "https://cep.is", // point at another instance + Timeout = TimeSpan.FromSeconds(10), // per attempt, connection included + MaxAttempts = 3, // counting the first; 1 disables retrying + BackoffBase = TimeSpan.FromMilliseconds(500), // base of the exponential wait + BackoffMax = TimeSpan.FromSeconds(8), // cap on each wait + TotalTimeout = TimeSpan.FromSeconds(15), // cap on the whole call; unset by default +}); + +Address address = await client.LookupAsync("01001000"); +``` + +Retries cover 429, 5xx and network failures, with exponential backoff and +jitter; 400 and 404 are never retried. Every setting is documented on +`CepisClientOptions`. + +`CepisClient.Default` is a shared client with the default configuration, built +on first use. It applies process-wide, including to third-party libraries that +also use this package, so do not dispose it; need a different configuration? +Build your own. + +To take the `HttpClient` from `IHttpClientFactory` or a container, pass it in — +it is then neither reconfigured nor disposed by this client: + +```csharp +var client = new CepisClient(httpClientFactory.CreateClient("cepis")); +``` + +### Pointing at a local instance + +`BaseUrl` is the only setting that usually changes between environments. The API +repo brings a local instance up with `just up`: + +```csharp +using var client = new CepisClient(new CepisClientOptions +{ + BaseUrl = "http://localhost:8080", +}); +``` + +## Errors + +Everything derives from `CepisException`. Below that, the split is by **the +decision you have to make**, not by the HTTP status that caused it: + +| error | when | does retrying help? | +| --- | --- | --- | +| `InvalidCepException` | not 8 digits | no | +| `CepNotFoundException` | well-formed, not in the database | no | +| `RateLimitedException` | 429; carries `RetryAfter` | yes, later | +| `ServerErrorException` | 5xx; carries `Status` | yes, later | +| `NetworkException` | DNS, connection, timeout, TLS | yes, later | +| `InvalidResponseException` | unintelligible response | probably not | + +```csharp +try +{ + Address address = await client.LookupAsync(cepFromForm); +} +catch (CepNotFoundException) +{ + // ask the user for another CEP +} +catch (CepisException) +{ + // network, server, rate limit: reschedule +} +``` + +C# allows one base class, so these are not also `ArgumentException` or +`KeyNotFoundException`: one base type that catches every failure is worth more. +A cancellation you asked for is not a `CepisException` — it surfaces as +`OperationCanceledException`, untouched. + +`InvalidResponseException` almost always 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. + +## Development + +Everything runs through `make` and the .NET SDK: + +```sh +make # list the targets +make test # run the suite against a local HTTP server +make lint # dotnet format --verify-no-changes + build -warnaserror, touches nothing +make format # apply dotnet format +make build # build the solution +make pack # the NuGet package into artifacts/ +make check # lint, then test, then build +``` + +## Licence + +ISC. See [LICENSE](LICENSE). 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; + +/// +/// A CEP and everything the database knows about it. +/// +/// +/// +/// 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 +/// https://cep.is/01001000 is what you access on the object. The +/// mapping from wire name to property is: +/// +/// +/// wireproperty +/// cep +/// logradouro +/// complemento +/// unidade +/// bairro +/// localidade +/// uf +/// estado +/// regiao +/// ibge +/// gia +/// ddd +/// siafi +/// geo +/// +/// +/// Fields the database does not have for that CEP come back as empty strings, +/// never — that is what the API returns. +/// +/// +/// is the exception: it is when there +/// is no coordinate. There the difference is real, because the API omits +/// the whole field in that case (about 43% of CEPs) rather than sending null. +/// It is also always for an address that came from +/// , +/// which carries no coordinate at all. +/// +/// +/// 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 : two addresses +/// describing the same place are equal even if one of them was built by hand. +/// +/// +public sealed record Address +{ + /// The eight digits, always unmasked. Wire name cep. + [JsonPropertyName("cep")] + public string Cep { get; init; } = ""; + + /// The street. Wire name logradouro. + [JsonPropertyName("logradouro")] + public string Logradouro { get; init; } = ""; + + /// + /// The qualifier on the street, such as a range of numbers. Wire name + /// complemento. + /// + [JsonPropertyName("complemento")] + public string Complemento { get; init; } = ""; + + /// + /// The named unit, for a CEP that belongs to one. Wire name unidade. + /// + [JsonPropertyName("unidade")] + public string Unidade { get; init; } = ""; + + /// The neighbourhood. Wire name bairro. + [JsonPropertyName("bairro")] + public string Bairro { get; init; } = ""; + + /// The municipality. Wire name localidade. + [JsonPropertyName("localidade")] + public string Localidade { get; init; } = ""; + + /// The two-letter state code. Wire name uf. + [JsonPropertyName("uf")] + public string UF { get; init; } = ""; + + /// The state's name. Wire name estado. + [JsonPropertyName("estado")] + public string Estado { get; init; } = ""; + + /// The region of the country. Wire name regiao. + [JsonPropertyName("regiao")] + public string Regiao { get; init; } = ""; + + /// The IBGE municipality code. Wire name ibge. + [JsonPropertyName("ibge")] + public string Ibge { get; init; } = ""; + + /// The GIA code, São Paulo only. Wire name gia. + [JsonPropertyName("gia")] + public string Gia { get; init; } = ""; + + /// The telephone area code. Wire name ddd. + [JsonPropertyName("ddd")] + public string Ddd { get; init; } = ""; + + /// The SIAFI municipality code. Wire name siafi. + [JsonPropertyName("siafi")] + public string Siafi { get; init; } = ""; + + /// + /// The coordinate, or when there is none. Wire name + /// geo. + /// + /// + /// A malformed geo block yields rather than + /// failing the whole response: the rest of the address is still useful. + /// + [JsonPropertyName("geo")] + [JsonConverter(typeof(GeoConverter))] + public Geo? Geo { get; init; } + + /// + /// Fields the API returned that this version of the library does not model. + /// + /// + /// A new field in the API therefore cannot break this client, and cannot be + /// silently dropped either. Empty for an address built by hand. + /// + [JsonExtensionData] + public Dictionary? Extra { get; init; } + + /// The CEP with its mask, 01001-000. + /// + /// 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. + /// + [JsonIgnore] + public string FormattedCep => Cep.Length == 8 ? $"{Cep[..5]}-{Cep[5..]}" : Cep; + + /// Compare every field but . + /// the address to compare with + /// when both describe the same place + 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; + + /// Hash every field but . + /// a hash consistent with + 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; + +/// +/// The CEP is well-formed but does not exist in the DNE database. +/// +/// +/// Retrying does not help: the answer is definitive until the next fortnightly +/// database. A batch never throws this — a CEP with no address is +/// at its position instead. +/// +public sealed class CepNotFoundException : CepisException +{ + /// Create the exception with a message. + /// what went wrong + public CepNotFoundException(string message) + : base(message) + { + } + + /// Create the exception with a message and an underlying cause. + /// what went wrong + /// the failure underneath + 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 @@ + + + + net8.0 + enable + enable + 12.0 + Cepis + Cepis + true + true + true + latest + + + + Cepis + 0.1.0 + Murilo Ijanc + Client for the public CEP API at cep.is. Official Correios data (DNE), refreshed every two weeks. + ISC + README.md + https://cep.is + cep;correios;brasil;address;geocoding + true + snupkg + + + + + + + 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; + +/// +/// Client for the public CEP API at cep.is. +/// +/// +/// +/// Build one and reuse it. The configuration is immutable, no method keeps +/// state between calls, and the single underlying +/// 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. +/// +/// +/// +/// using var client = new CepisClient(new CepisClientOptions +/// { +/// BaseUrl = "http://localhost:8080", +/// }); +/// +/// Address address = await client.LookupAsync("01001-000"); +/// +/// +/// +/// For a script, is a shared client with the default +/// configuration, built on first use. +/// +/// +/// 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. +/// +/// +/// A note on names: the API surface is English, but the data fields keep the +/// API's own Portuguese names (, +/// , ), with only +/// the casing adapted. What you read in the JSON is what you access on the +/// object. +/// +/// +public sealed class CepisClient : IDisposable +{ + /// Where the public API lives. + public const string DefaultUrl = "https://cep.is"; + + /// How many CEPs the batch endpoint accepts in one request. + public const int MaxBatch = 500; + + /// The version of this library. + 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 RetriableStatus = + new[] { 429, 500, 502, 503, 504 }.ToFrozenSet(); + + private static readonly FrozenSet OkOnly = new[] { 200 }.ToFrozenSet(); + + private static readonly FrozenSet HealthStatus = new[] { 200, 503 }.ToFrozenSet(); + + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static readonly Lazy SharedDefault = new(() => new CepisClient()); + + private readonly HttpClient _http; + private readonly bool _ownsHttp; + private readonly string _baseUrl; + private readonly CepisClientOptions _options; + + /// Build a client with the default configuration. + public CepisClient() + : this(new CepisClientOptions()) + { + } + + /// Build a client with its own . + /// the configuration + /// is null + /// a setting is out of range + public CepisClient(CepisClientOptions options) + : this(NewHttpClient(), options, ownsHttp: true) + { + } + + /// + /// Build a client over an the caller owns. + /// + /// + /// For applications that resolve their from + /// IHttpClientFactory or a container. The instance is neither + /// reconfigured nor disposed by this client, so its own + /// still applies on top of + /// — whichever is shorter wins. + /// + /// the transport to use + /// the configuration, or null for the defaults + /// is null + /// a setting is out of range + 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)"; + } + + /// + /// A shared client with the default configuration, built on first use. + /// + /// + /// 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. + /// + public static CepisClient Default => SharedDefault.Value; + + /// Where the API lives, without a trailing slash. + public string BaseUrl => _baseUrl; + + /// The configuration this client was built with. + public CepisClientOptions Options => _options; + + /// The User-Agent this client sends. + public string UserAgent { get; } + + /// + /// Return the CEP's eight digits, accepting the mask. + /// + /// + /// "01001-000", "01001000" and " 01001-000 " all become + /// "01001000". This runs before 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. + /// + /// the CEP, masked or not + /// the eight digits + /// unless exactly eight digits remain + 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; + } + + /// + /// Look up a single CEP. + /// + /// + /// This is the only call that carries a coordinate. About 57% of CEPs have + /// one; when they do not, is + /// . + /// + /// the CEP, masked or not + /// cancels the call, waits included + /// the address + /// + /// the CEP is not eight digits, in which case no request is made, or the API + /// rejected it with a 400 + /// + /// well-formed, but not in the database + /// the API answered 429 on every attempt + /// the API answered 5xx on every attempt + /// the request never completed + /// the body cannot be read + public async Task
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
(reply.Body); + } + + /// + /// Look up many CEPs in one batch. + /// + /// + /// + /// The returned list has the same length and the same order as the + /// input. A CEP that does not exist becomes at its + /// position — it does not vanish from the list, and it does not throw. That + /// is the right difference from + /// : in a batch of a + /// thousand, one bad CEP must not cost the other nine hundred and + /// ninety-nine. + /// + /// + /// A malformed CEP also becomes , for the same reason. + /// If you need to know which one was malformed, validate first with + /// . + /// + /// + /// Input longer than is chunked into that many CEPs + /// per request automatically. + /// + /// + /// Batches carry no coordinate. The endpoint behind them is CSV, and + /// the CSV has no geo column. Every address returned here has + /// , including for CEPs that + /// do have one. Need the coordinate? Use + /// , one at a time. + /// + /// + /// the CEPs, masked or not + /// cancels the call, waits included + /// + /// one entry per input CEP, in order, where there is + /// no address + /// + /// is null + /// the API answered 429 on every attempt + /// the API answered 5xx on every attempt + /// a request never completed + public async Task> LookupManyAsync( + IEnumerable ceps, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ceps); + + var wanted = ceps as IReadOnlyList ?? [.. ceps]; + if (wanted.Count == 0) + { + return []; + } + + var result = new List(wanted.Count); + for (var start = 0; start < wanted.Count; start += MaxBatch) + { + var end = Math.Min(start + MaxBatch, wanted.Count); + var chunk = new List(end - start); + for (var i = start; i < end; i++) + { + chunk.Add(wanted[i]); + } + + result.AddRange(await LookupChunkAsync(chunk, cancellationToken).ConfigureAwait(false)); + } + + return result; + } + + /// + /// Query GET /health: does the API answer, and with which database. + /// + /// + /// An API reporting itself degraded does not throw — that arrives in + /// , and checking health is exactly when you want + /// the diagnosis rather than an exception. Network failures still throw. + /// + /// cancels the call, waits included + /// the API's health + /// the request never completed + /// the body cannot be read + public async Task HealthAsync(CancellationToken cancellationToken = default) + { + var reply = await RequestAsync("/health", HealthStatus, cancellationToken).ConfigureAwait(false); + return Deserialize(reply.Body); + } + + /// + /// Release the this client created for itself. + /// + /// + /// An handed in by the caller is left alone. + /// + public void Dispose() + { + if (_ownsHttp) + { + _http.Dispose(); + } + } + + /// + /// Parse the Retry-After header in both RFC 9110 forms. + /// + /// + /// It may arrive as a number of seconds ("120") or as an HTTP date + /// ("Wed, 21 Oct 2026 07:28:00 GMT"). Handling only the first is the + /// common bug, and it makes a client ignore exactly the wait request that + /// matters most. + /// + /// the header value, if any + /// the wait, or null when there is nothing usable + 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(byte[] body) + where T : class + { + T? value; + try + { + value = JsonSerializer.Deserialize(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(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)); + + /// + /// Turn one CSV record into an address, or null when there is none. + /// + /// + /// 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 cep column + /// of means "not found". + /// + /// the record, by column name + /// the address, or null + private static Address? AddressFromRow(Dictionary row) + { + if (!row.TryGetValue("localidade", out var localidade) || string.IsNullOrWhiteSpace(localidade)) + { + return null; + } + + return JsonSerializer.SerializeToElement(row).Deserialize
(JsonOptions); + } + + private static string? FirstHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null; + + /// + /// One chunk of at most CEPs, in a single request. + /// + /// + /// 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. + /// + /// the chunk + /// cancels the call + /// one entry per CEP in the chunk, in order + private async Task> LookupChunkAsync( + List 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(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; + } + + /// + /// Perform the GET, with retries. + /// + /// + /// + /// Definitive 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. + /// + /// + /// 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. + /// + /// + /// the path, query included + /// statuses to hand back rather than retry + /// cancels the call + /// the status and the body + private async Task RequestAsync( + string path, + FrozenSet 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); + } + + /// + /// Wait out the backoff, reporting whether there is still budget to retry. + /// + /// + /// The wait is cut short by , so a + /// that expires mid-wait ends + /// the call rather than being overrun. A cancellation the caller asked for + /// propagates instead. + /// + /// which attempt just failed, from one + /// what the server asked for, if anything + /// the deadline, or the caller's token + /// the caller's token alone + /// false when the budget is spent + private async Task 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; + } + } + + /// + /// How long to wait before the next attempt. + /// + /// + /// The server's Retry-After wins over the local calculation when + /// present: it knows when it will be back, we are guessing. It still goes + /// through the cap. Otherwise + /// the wait is drawn from [0, ceiling] rather than being the ceiling. + /// + /// which attempt just failed, from one + /// what the server asked for, if anything + /// the wait + 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; + +/// +/// How a behaves. Every setting has a default; the +/// one that usually changes between environments is . +/// +/// +/// +/// using var client = new CepisClient(new CepisClientOptions +/// { +/// BaseUrl = "http://localhost:8080", +/// TotalTimeout = TimeSpan.FromSeconds(15), +/// }); +/// +/// +public sealed record CepisClientOptions +{ + /// + /// Where the API lives. Defaults to . + /// + /// + /// 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. + /// + public string BaseUrl { get; init; } = CepisClient.DefaultUrl; + + /// + /// How long a single attempt may take, connection included. Defaults to ten + /// seconds. + /// + /// + /// 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. is the cap on the whole call. + /// + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10); + + /// + /// How many attempts in total, counting the first. Defaults to three. + /// + /// + /// 1 disables retrying. The default of 3 covers the common + /// case, one transient failure, without turning a real outage into a long + /// wait. + /// + public int MaxAttempts { get; init; } = 3; + + /// + /// The base of the exponential wait between attempts. Defaults to 500 + /// milliseconds. + /// + /// + /// The actual wait is drawn from [0, base * 2^(n-1)] — 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. + /// + public TimeSpan BackoffBase { get; init; } = TimeSpan.FromMilliseconds(500); + + /// + /// The cap on each individual wait. Defaults to eight seconds. + /// + /// + /// It applies to a server-sent Retry-After too, or a + /// Retry-After: 3600 from a misconfigured proxy would park the + /// caller for an hour. + /// + public TimeSpan BackoffMax { get; init; } = TimeSpan.FromSeconds(8); + + /// + /// The cap on the whole call, attempts and waits included. Unset by + /// default. + /// + /// + /// leaves as the only + /// bound. Set it in code serving a web request: without it the worst case + /// is times plus the sum of + /// the backoffs. + /// + public TimeSpan? TotalTimeout { get; init; } + + /// + /// Overrides the User-Agent. uses the + /// default. + /// + /// + /// The default identifies the library and its version, which is what lets + /// the server diagnose an old client misbehaving. + /// + 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; + +/// +/// The base of every error this library raises. Catch it to handle any failure. +/// +/// +/// +/// Below this type the split is by the decision the caller has to make, +/// not by the HTTP status that caused it. +/// and are +/// problems with the request: retrying is pointless, the same CEP will +/// fail again. , +/// and are problems with transport: +/// retrying may help, and the client already retried on its own before throwing +/// any of them. +/// +/// +/// C# allows one base class, so these are not also +/// or : one base type that catches every +/// failure is worth more. +/// +/// +public abstract class CepisException : Exception +{ + /// Create the exception with a message. + /// what went wrong + protected CepisException(string message) + : base(message) + { + } + + /// Create the exception with a message and an underlying cause. + /// what went wrong + /// the failure underneath, if any + 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; + +/// +/// An RFC 4180 reader for the batch endpoint. +/// +/// +/// Values may contain commas, quotes and newlines, so they arrive +/// double-quoted with "" 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. +/// +internal static class Csv +{ + /// + /// Parse a CSV document whose first record is the header. + /// + /// + /// Each record becomes a dictionary from column name to value. A record + /// with fewer fields than the header has the missing ones filled with + /// ""; extra fields are dropped. + /// + /// the document + /// one dictionary per data record, in order + internal static List> Parse(string text) + { + var records = Split(text); + var rows = new List>(); + 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(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> Split(string text) + { + var records = new List>(); + var record = new List(); + 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; + +/// +/// The CEP's coordinate, when the database has one. +/// +/// +/// +/// The point is the median 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. +/// +/// +/// Always check before using and +/// for distance. "logradouro" means the point is a +/// stretch of street and lands around 100–150 m — good for +/// maps and for distance. "localidade" means the CEP is the whole +/// municipality (a city-wide general CEP) and the point is its centroid, with +/// running from 7 km to 40 km; computing shipping from that +/// is wrong by tens of kilometres. +/// +/// +/// is the radius containing 90% of that CEP's addresses. It +/// exists precisely so that error cannot pass unnoticed. +/// +/// +/// The property names are the API's own, with only the casing adapted: +/// lat, lon, raio_m and precisao on the wire. +/// +/// +public sealed record Geo +{ + /// Latitude in decimal degrees, WGS 84. Wire name lat. + [JsonPropertyName("lat")] + public double Lat { get; init; } + + /// Longitude in decimal degrees, WGS 84. Wire name lon. + [JsonPropertyName("lon")] + public double Lon { get; init; } + + /// + /// The radius, in metres, containing 90% of that CEP's addresses. Wire name + /// raio_m. + /// + [JsonPropertyName("raio_m")] + public int RaioM { get; init; } + + /// + /// What the point describes: "logradouro" for a stretch of street, + /// "localidade" for a municipality centroid. Wire name + /// precisao. + /// + [JsonPropertyName("precisao")] + public string Precisao { get; init; } = ""; + + /// + /// when the coordinate points at a street rather than + /// a municipality. + /// + /// + /// Shorthand for Precisao == "logradouro", which is the check to make + /// before using the point to measure distance. + /// + [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; + +/// +/// Reads the geo block, yielding for anything it +/// cannot make sense of. +/// +/// +/// 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. +/// +internal sealed class GeoConverter : JsonConverter +{ + 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; + +/// +/// The response of GET /health. +/// +/// +/// Good for two things: checking that the API answers, and knowing which +/// version of the database is live. The DNE database changes every two +/// weeks; identifies it. +/// +public sealed record Health +{ + /// + /// "ok" when the API reports itself healthy. Wire name + /// status. + /// + [JsonPropertyName("status")] + public string Status { get; init; } = ""; + + /// + /// Identifier of the fortnightly DNE database behind the answers. Wire name + /// dne. + /// + [JsonPropertyName("dne")] + public string Dne { get; init; } = ""; + + /// How many CEPs that database holds. Wire name ceps. + [JsonPropertyName("ceps")] + public long Ceps { get; init; } + + /// + /// How many of those CEPs carry a coordinate. Wire name geo. + /// + [JsonPropertyName("geo")] + public long Geo { get; init; } + + /// + /// When the database was generated, as an ISO 8601 instant. Wire name + /// base_gerada_em. + /// + [JsonPropertyName("base_gerada_em")] + public string BaseGeradaEm { get; init; } = ""; + + /// + /// when the API reports itself healthy. + /// + [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; + +/// +/// The CEP is not eight digits. +/// +/// +/// Usually thrown without 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. +/// +public sealed class InvalidCepException : CepisException +{ + /// Create the exception with a message. + /// what went wrong + public InvalidCepException(string message) + : base(message) + { + } + + /// Create the exception with a message and an underlying cause. + /// what went wrong + /// the failure underneath + 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; + +/// +/// A success status came back with a body that cannot be parsed. +/// +/// +/// 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. +/// +public sealed class InvalidResponseException : CepisException +{ + /// Create the exception with a message. + /// what went wrong + public InvalidResponseException(string message) + : base(message) + { + } + + /// Create the exception with a message and an underlying cause. + /// what went wrong + /// the parse failure underneath + 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; + +/// +/// Reads any scalar as a string, and a JSON null as the empty string. +/// +/// +/// The API documents every text field as a string and an absent one as +/// "", never null. This converter makes a client that meets a +/// null, 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. +/// +internal sealed class LenientStringConverter : JsonConverter +{ + 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; + +/// +/// The request never completed: DNS, a refused connection, a timeout, TLS. +/// +/// +/// Thrown only after every configured attempt has failed. A cancellation +/// requested by the caller is not this — that surfaces as +/// , untouched. +/// +public sealed class NetworkException : CepisException +{ + /// Create the exception with a message. + /// what went wrong + public NetworkException(string message) + : base(message) + { + } + + /// Create the exception with a message and an underlying cause. + /// what went wrong + /// the transport failure underneath + 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; + +/// +/// The API, or the edge in front of it, answered 429 on every attempt. +/// +public sealed class RateLimitedException : CepisException +{ + /// Create the exception with a message and the wait requested. + /// what went wrong + /// + /// what the server asked for, or if it did not say + /// + public RateLimitedException(string message, TimeSpan? retryAfter = null) + : base(message) + { + RetryAfter = retryAfter; + } + + /// Create the exception with a message, the wait and a cause. + /// what went wrong + /// what the server asked for, if anything + /// the failure underneath + public RateLimitedException(string message, TimeSpan? retryAfter, Exception? innerException) + : base(message, innerException) + { + RetryAfter = retryAfter; + } + + /// + /// How long the server asked the caller to wait, or + /// if it did not say. + /// + /// + /// The client already honoured this across the attempts it made, capped at + /// . It is exposed here for + /// callers who want to reschedule the work. + /// + 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; + +/// +/// The API answered 5xx on every attempt. +/// +public sealed class ServerErrorException : CepisException +{ + /// Create the exception with a message and the status seen. + /// what went wrong + /// the HTTP status of the last attempt + public ServerErrorException(string message, int status) + : base(message) + { + Status = status; + } + + /// Create the exception with a message, the status and a cause. + /// what went wrong + /// the HTTP status of the last attempt + /// the failure underneath + public ServerErrorException(string message, int status, Exception? innerException) + : base(message, innerException) + { + Status = status; + } + + /// The HTTP status seen on the last attempt. + public int Status { get; } +} diff --git a/tests/Cepis.Tests/BatchTests.cs b/tests/Cepis.Tests/BatchTests.cs new file mode 100644 index 0000000..8e075f1 --- /dev/null +++ b/tests/Cepis.Tests/BatchTests.cs @@ -0,0 +1,120 @@ +using System.Text; + +namespace Cepis.Tests; + +/// The batch: order, absences, chunking, and the missing coordinate. +public sealed class BatchTests : HttpTestBase +{ + [Fact] + public async Task KeepsTheRequestedOrderAndLength() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvBatch)); + + var result = await Client.LookupManyAsync(["01001000", "99999999", "20040020"]); + + Assert.Equal(3, result.Count); + Assert.Equal("São Paulo", result[0]?.Localidade); + Assert.Null(result[1]); + Assert.Equal("Rio de Janeiro", result[2]?.Localidade); + } + + [Fact] + public async Task CarriesNoCoordinate() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvBatch)); + + var result = await Client.LookupManyAsync(["01001000"]); + + Assert.NotNull(result[0]); + Assert.Null(result[0]!.Geo); + } + + [Fact] + public async Task AMalformedCepIsNullRatherThanAnError() + { + Server.Handle(call => Reply.Of(200, Csv(call))); + + var result = await Client.LookupManyAsync(["01001000", "oops", "20040020"]); + + Assert.Equal(3, result.Count); + Assert.NotNull(result[0]); + Assert.Null(result[1]); + Assert.NotNull(result[2]); + } + + [Fact] + public async Task ChunksAboveFiveHundred() + { + Server.Handle(call => Reply.Of(200, Csv(call))); + var ceps = Enumerable.Range(0, 600).Select(i => $"{i:D8}").ToList(); + + var result = await Client.LookupManyAsync(ceps); + + Assert.Equal(600, result.Count); + Assert.Equal(2, Server.Calls.Count); + Assert.Equal(CepisClient.MaxBatch, CountCeps(Server.Calls[0])); + Assert.Equal(100, CountCeps(Server.Calls[1])); + Assert.All(result, address => Assert.NotNull(address)); + } + + [Fact] + public async Task ExactlyFiveHundredIsOneRequest() + { + Server.Handle(call => Reply.Of(200, Csv(call))); + var ceps = Enumerable.Range(0, CepisClient.MaxBatch).Select(i => $"{i:D8}").ToList(); + + var result = await Client.LookupManyAsync(ceps); + + Assert.Equal(CepisClient.MaxBatch, result.Count); + Assert.Single(Server.Calls); + } + + [Fact] + public async Task AShortResponseIsPaddedRatherThanShifted() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvHeader + "\n" + Fixtures.CsvRow("01001000") + "\n")); + + var result = await Client.LookupManyAsync(["01001000", "20040020", "30140071"]); + + Assert.Equal(3, result.Count); + Assert.Equal("01001000", result[0]?.Cep); + Assert.Null(result[1]); + Assert.Null(result[2]); + } + + [Fact] + public async Task AnEmptyInputMakesNoRequest() + { + var result = await Client.LookupManyAsync([]); + + Assert.Empty(result); + Assert.Empty(Server.Calls); + } + + [Fact] + public async Task ValuesWithCommasSurviveTheParse() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvHeader + "\n" + + "\"01001000\",\"Praça da Sé, lado ímpar\",,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107\n")); + + var result = await Client.LookupManyAsync(["01001000"]); + + Assert.Equal("Praça da Sé, lado ímpar", result[0]?.Logradouro); + } + + private static string Csv(Call call) + { + var body = new StringBuilder(Fixtures.CsvHeader); + foreach (var cep in Requested(call)) + { + body.Append('\n').Append(cep.Length == 8 ? Fixtures.CsvRow(cep) : cep + ",,,,,,,,,,,,"); + } + + return body.Append('\n').ToString(); + } + + private static string[] Requested(Call call) => + (call.Ceps ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries); + + private static int CountCeps(Call call) => Requested(call).Length; +} diff --git a/tests/Cepis.Tests/Cepis.Tests.csproj b/tests/Cepis.Tests/Cepis.Tests.csproj new file mode 100644 index 0000000..40faa78 --- /dev/null +++ b/tests/Cepis.Tests/Cepis.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + 12.0 + false + true + + + + + + + + + + + + + + + + + diff --git a/tests/Cepis.Tests/ConfigurationTests.cs b/tests/Cepis.Tests/ConfigurationTests.cs new file mode 100644 index 0000000..2f6fab9 --- /dev/null +++ b/tests/Cepis.Tests/ConfigurationTests.cs @@ -0,0 +1,80 @@ +namespace Cepis.Tests; + +/// What the client accepts as configuration, and what it refuses. +public sealed class ConfigurationTests : HttpTestBase +{ + [Fact] + public void TheDefaultsAreTheDocumentedOnes() + { + var options = new CepisClientOptions(); + + Assert.Equal(CepisClient.DefaultUrl, options.BaseUrl); + Assert.Equal(TimeSpan.FromSeconds(10), options.Timeout); + Assert.Equal(3, options.MaxAttempts); + Assert.Equal(TimeSpan.FromMilliseconds(500), options.BackoffBase); + Assert.Equal(TimeSpan.FromSeconds(8), options.BackoffMax); + Assert.Null(options.TotalTimeout); + Assert.Null(options.UserAgent); + } + + [Fact] + public void TheDefaultUserAgentIdentifiesTheLibrary() + { + using var client = new CepisClient(); + + Assert.Equal($"cepis-csharp/{CepisClient.Version} (+https://cep.is)", client.UserAgent); + } + + [Fact] + public async Task ATrailingSlashCannotDoubleTheSeparator() + { + using var client = new CepisClient(new CepisClientOptions { BaseUrl = Server.Url + "///" }); + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + await client.LookupAsync("01001000"); + + Assert.Equal(Server.Url, client.BaseUrl); + Assert.Equal("/01001000", Assert.Single(Server.Calls).Path); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxAttemptsBelowOneIsRefused(int attempts) => + Assert.Throws( + () => new CepisClient(new CepisClientOptions { MaxAttempts = attempts })); + + [Fact] + public void ATimeoutOfZeroIsRefused() => + Assert.Throws( + () => new CepisClient(new CepisClientOptions { Timeout = TimeSpan.Zero })); + + [Fact] + public void AnEmptyBaseUrlIsRefused() => + Assert.Throws( + () => new CepisClient(new CepisClientOptions { BaseUrl = " " })); + + [Fact] + public void ATotalTimeoutOfZeroIsRefused() => + Assert.Throws( + () => new CepisClient(new CepisClientOptions { TotalTimeout = TimeSpan.Zero })); + + [Fact] + public async Task AnInjectedHttpClientIsUsedAndNotDisposed() + { + using var http = new HttpClient(); + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + using (var client = new CepisClient(http, new CepisClientOptions { BaseUrl = Server.Url })) + { + await client.LookupAsync("01001000"); + } + + Assert.Equal(1, Server.Count("/01001000")); + await http.GetAsync(new Uri(Server.Url + "/01001000")); + } + + [Fact] + public void TheSharedDefaultPointsAtTheRealApi() => + Assert.Equal(CepisClient.DefaultUrl, CepisClient.Default.BaseUrl); +} diff --git a/tests/Cepis.Tests/CsvTests.cs b/tests/Cepis.Tests/CsvTests.cs new file mode 100644 index 0000000..97a353f --- /dev/null +++ b/tests/Cepis.Tests/CsvTests.cs @@ -0,0 +1,79 @@ +namespace Cepis.Tests; + +/// The CSV reader behind the batch endpoint. +public sealed class CsvTests +{ + [Fact] + public void ReadsTheHeaderAndOneRecord() + { + var rows = Csv.Parse("a,b,c\n1,2,3\n"); + + var row = Assert.Single(rows); + Assert.Equal("1", row["a"]); + Assert.Equal("2", row["b"]); + Assert.Equal("3", row["c"]); + } + + [Fact] + public void AQuotedValueMayContainACommaAndANewline() + { + var rows = Csv.Parse("a,b\n\"Praça da Sé, lado ímpar\",\"two\nlines\"\n"); + + var row = Assert.Single(rows); + Assert.Equal("Praça da Sé, lado ímpar", row["a"]); + Assert.Equal("two\nlines", row["b"]); + } + + [Fact] + public void DoubledQuotesAreOneQuote() + { + var rows = Csv.Parse("a\n\"say \"\"hi\"\"\"\n"); + + Assert.Equal("say \"hi\"", Assert.Single(rows)["a"]); + } + + [Theory] + [InlineData("a,b\n1,2\n")] + [InlineData("a,b\r\n1,2\r\n")] + [InlineData("a,b\r1,2\r")] + [InlineData("a,b\n1,2")] + public void EveryLineEndingWorks(string text) + { + var rows = Csv.Parse(text); + + Assert.Equal("1", Assert.Single(rows)["a"]); + } + + [Fact] + public void ALeadingByteOrderMarkIsDropped() + { + var rows = Csv.Parse("\uFEFFa,b\n1,2\n"); + + Assert.Equal("1", Assert.Single(rows)["a"]); + } + + [Fact] + public void AnEmptyValueStaysEmpty() + { + var rows = Csv.Parse("a,b,c\n1,,3\n"); + + Assert.Equal("", Assert.Single(rows)["b"]); + } + + [Fact] + public void AShortRecordIsFilledOut() + { + var rows = Csv.Parse("a,b,c\n1\n"); + + var row = Assert.Single(rows); + Assert.Equal("1", row["a"]); + Assert.Equal("", row["b"]); + Assert.Equal("", row["c"]); + } + + [Fact] + public void AHeaderAloneYieldsNoRecords() => Assert.Empty(Csv.Parse("a,b,c\n")); + + [Fact] + public void AnEmptyDocumentYieldsNoRecords() => Assert.Empty(Csv.Parse("")); +} diff --git a/tests/Cepis.Tests/FakeServer.cs b/tests/Cepis.Tests/FakeServer.cs new file mode 100644 index 0000000..1bd4d60 --- /dev/null +++ b/tests/Cepis.Tests/FakeServer.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Cepis.Tests; + +/// One canned response. +public sealed record Reply(int Status, string Body, IReadOnlyDictionary? Headers = null) +{ + /// A reply with no extra headers. + public static Reply Of(int status, string body) => new(status, body); + + /// A reply carrying one extra header. + public static Reply Of(int status, string body, string header, string value) => + new(status, body, new Dictionary(StringComparer.Ordinal) { [header] = value }); +} + +/// One request the server saw. +public sealed record Call(string Path, string? Ceps); + +/// +/// A throwaway HTTP server on a free loopback port, with programmable routes and +/// a request log. +/// +/// +/// The tests point a real at it, so the code path they +/// exercise is the one that runs in production, request construction and error +/// body reading included. It doubles as the test that BaseUrl works: if +/// it did not, none of the suite would run. +/// +public sealed class FakeServer : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly Dictionary> _routes = new(StringComparer.Ordinal); + private readonly List _calls = []; + private readonly object _gate = new(); + private Func? _handler; + + /// Start the server on a free port. + public FakeServer() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + + Url = $"http://127.0.0.1:{port}"; + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + _listener.Start(); + _ = Task.Run(LoopAsync); + } + + /// Where the server listens, without a trailing slash. + public string Url { get; } + + /// Every request seen so far, in order. + public IReadOnlyList Calls + { + get + { + lock (_gate) + { + return [.. _calls]; + } + } + } + + /// + /// Register the replies for a path. Each request consumes the next one; the + /// last repeats once the list runs out, which is how retrying is tested. + /// + public void Respond(string path, params Reply[] replies) + { + lock (_gate) + { + _routes[path] = new Queue(replies); + } + } + + /// Answer every request with this function instead of the routes. + public void Handle(Func handler) + { + lock (_gate) + { + _handler = handler; + } + } + + /// How many requests so far went to that path. + public int Count(string path) + { + lock (_gate) + { + return _calls.Count(call => call.Path == path); + } + } + + /// Stop the server. + public void Dispose() => _listener.Close(); + + private async Task LoopAsync() + { + while (_listener.IsListening) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync().ConfigureAwait(false); + } + catch (Exception stopped) when (stopped is HttpListenerException + or ObjectDisposedException + or InvalidOperationException) + { + return; + } + + _ = Task.Run(() => Serve(context)); + } + } + + private void Serve(HttpListenerContext context) + { + var call = new Call(context.Request.Url!.AbsolutePath, context.Request.QueryString["ceps"]); + var reply = Next(call); + var body = Encoding.UTF8.GetBytes(reply.Body); + + try + { + context.Response.StatusCode = reply.Status; + if (reply.Headers is not null) + { + foreach (var (name, value) in reply.Headers) + { + context.Response.Headers[name] = value; + } + } + + context.Response.ContentLength64 = body.Length; + context.Response.OutputStream.Write(body); + context.Response.OutputStream.Close(); + } + catch (Exception gone) when (gone is HttpListenerException or ObjectDisposedException or IOException) + { + } + } + + private Reply Next(Call call) + { + Func handler; + lock (_gate) + { + _calls.Add(call); + if (_handler is null) + { + if (!_routes.TryGetValue(call.Path, out var queue) || queue.Count == 0) + { + return Reply.Of(404, Fixtures.NotFound); + } + + return queue.Count > 1 ? queue.Dequeue() : queue.Peek(); + } + + handler = _handler; + } + + return handler(call); + } +} diff --git a/tests/Cepis.Tests/Fixtures.cs b/tests/Cepis.Tests/Fixtures.cs new file mode 100644 index 0000000..648f881 --- /dev/null +++ b/tests/Cepis.Tests/Fixtures.cs @@ -0,0 +1,69 @@ +namespace Cepis.Tests; + +/// Bodies the API really returns, copied from it. +public static class Fixtures +{ + /// A CEP with a street-precision coordinate. + public const string SeAddress = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar", + "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP", + "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004", + "ddd":"11","siafi":"7107", + "geo":{"lat":-23.567202,"lon":-46.649659,"raio_m":1084,"precisao":"logradouro"}} + """; + + /// A CEP the database has no coordinate for. + public const string NoGeoAddress = """ + {"cep":"20040020","logradouro":"Avenida Rio Branco","complemento":"", + "unidade":"","bairro":"Centro","localidade":"Rio de Janeiro","uf":"RJ", + "estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3304557","gia":"", + "ddd":"21","siafi":"6001"} + """; + + /// A coordinate whose numbers cannot be read. + public const string BrokenGeoAddress = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"","unidade":"", + "bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo", + "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107", + "geo":{"lat":"nowhere","precisao":"logradouro"}} + """; + + /// A response carrying a field this library does not model. + public const string FutureAddress = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"","unidade":"", + "bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo", + "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107", + "cep_pai":"01000000"} + """; + + /// The 404 body. + public const string NotFound = + """{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"""; + + /// The 400 body. + public const string InvalidCep = + """{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}"""; + + /// The 500 body. + public const string InternalError = """{"erro":"interno","mensagem":"falha interna"}"""; + + /// A healthy API. + public const string Healthy = """ + {"status":"ok","dne":"26071","ceps":1596168,"geo":894489, + "base_gerada_em":"2026-07-24T03:48:28Z"} + """; + + /// The batch header row. + public const string CsvHeader = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; + + /// Three CEPs, the middle one absent from the database. + public const string CsvBatch = CsvHeader + "\n" + + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107\n" + + "99999999,,,,,,,,,,,,\n" + + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001\n"; + + /// One data row for a CEP, in the batch's column order. + public static string CsvRow(string cep) => + $"{cep},Rua Teste,,,Centro,Cidade,SP,São Paulo,Sudeste,3550308,,11,7107"; +} diff --git a/tests/Cepis.Tests/HealthTests.cs b/tests/Cepis.Tests/HealthTests.cs new file mode 100644 index 0000000..2855d72 --- /dev/null +++ b/tests/Cepis.Tests/HealthTests.cs @@ -0,0 +1,46 @@ +namespace Cepis.Tests; + +/// The health endpoint, which reports rather than throws. +public sealed class HealthTests : HttpTestBase +{ + [Fact] + public async Task ReadsAHealthyApi() + { + Server.Respond("/health", Reply.Of(200, Fixtures.Healthy)); + + var health = await Client.HealthAsync(); + + Assert.True(health.Ok); + Assert.Equal("ok", health.Status); + Assert.Equal("26071", health.Dne); + Assert.Equal(1596168, health.Ceps); + Assert.Equal(894489, health.Geo); + Assert.Equal("2026-07-24T03:48:28Z", health.BaseGeradaEm); + } + + [Fact] + public async Task ADegradedApiReportsRatherThanThrows() + { + Server.Respond("/health", Reply.Of(503, + """{"status":"degradado","dne":"26071","ceps":0,"geo":0,"base_gerada_em":""}""")); + + var health = await Client.HealthAsync(); + + Assert.False(health.Ok); + Assert.Equal("degradado", health.Status); + Assert.Equal(1, Server.Count("/health")); + } + + [Fact] + public async Task AnUnreachableApiStillThrows() + { + using var unreachable = new CepisClient(new CepisClientOptions + { + BaseUrl = "http://127.0.0.1:1", + MaxAttempts = 1, + Timeout = TimeSpan.FromSeconds(30), + }); + + await Assert.ThrowsAsync(() => unreachable.HealthAsync()); + } +} diff --git a/tests/Cepis.Tests/HttpTestBase.cs b/tests/Cepis.Tests/HttpTestBase.cs new file mode 100644 index 0000000..115ceef --- /dev/null +++ b/tests/Cepis.Tests/HttpTestBase.cs @@ -0,0 +1,33 @@ +namespace Cepis.Tests; + +/// A fake API and a real client pointed at it, torn down per test. +public abstract class HttpTestBase : IDisposable +{ + /// The fake API. + protected FakeServer Server { get; } = new(); + + /// + /// A client whose backoff is shortened so the suite stays fast, and whose + /// per-attempt timeout is deliberately generous: a loaded machine must not + /// be able to turn a passing test into a spurious network failure. + /// + protected CepisClient Client { get; } + + /// Start the server and point a client at it. + protected HttpTestBase() => + Client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromSeconds(30), + BackoffBase = TimeSpan.FromMilliseconds(1), + BackoffMax = TimeSpan.FromMilliseconds(20), + }); + + /// Stop the server and release the client. + public void Dispose() + { + Client.Dispose(); + Server.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/tests/Cepis.Tests/LookupTests.cs b/tests/Cepis.Tests/LookupTests.cs new file mode 100644 index 0000000..ad3e2bf --- /dev/null +++ b/tests/Cepis.Tests/LookupTests.cs @@ -0,0 +1,168 @@ +namespace Cepis.Tests; + +/// A single lookup: the happy path, the coordinate, and the refusals. +public sealed class LookupTests : HttpTestBase +{ + [Fact] + public async Task ReadsEveryField() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001-000"); + + Assert.Equal("01001000", address.Cep); + Assert.Equal("01001-000", address.FormattedCep); + Assert.Equal("Praça da Sé", address.Logradouro); + Assert.Equal("lado ímpar", address.Complemento); + Assert.Equal("", address.Unidade); + Assert.Equal("Sé", address.Bairro); + Assert.Equal("São Paulo", address.Localidade); + Assert.Equal("SP", address.UF); + Assert.Equal("São Paulo", address.Estado); + Assert.Equal("Sudeste", address.Regiao); + Assert.Equal("3550308", address.Ibge); + Assert.Equal("1004", address.Gia); + Assert.Equal("11", address.Ddd); + Assert.Equal("7107", address.Siafi); + } + + [Fact] + public async Task TheMaskIsStrippedBeforeTheRequest() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + await Client.LookupAsync("01001-000"); + + Assert.Equal("/01001000", Assert.Single(Server.Calls).Path); + } + + [Fact] + public async Task ReadsTheCoordinate() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001000"); + + var geo = address.Geo; + Assert.NotNull(geo); + Assert.Equal(-23.567202, geo.Lat, 6); + Assert.Equal(-46.649659, geo.Lon, 6); + Assert.Equal(1084, geo.RaioM); + Assert.Equal("logradouro", geo.Precisao); + Assert.True(geo.IsPrecise); + } + + [Fact] + public async Task AnAbsentCoordinateIsNull() + { + Server.Respond("/20040020", Reply.Of(200, Fixtures.NoGeoAddress)); + + var address = await Client.LookupAsync("20040020"); + + Assert.Null(address.Geo); + Assert.Equal("Rio de Janeiro", address.Localidade); + } + + [Fact] + public async Task AMalformedCoordinateDoesNotLoseTheAddress() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.BrokenGeoAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Null(address.Geo); + Assert.Equal("São Paulo", address.Localidade); + } + + [Fact] + public async Task ACoordinateOfTheWrongShapeIsDropped() + { + Server.Respond("/01001000", Reply.Of(200, """{"cep":"01001000","localidade":"X","geo":[]}""")); + + var address = await Client.LookupAsync("01001000"); + + Assert.Null(address.Geo); + Assert.Equal("X", address.Localidade); + } + + [Fact] + public async Task AFieldThisLibraryDoesNotKnowIsKept() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.FutureAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Equal("São Paulo", address.Localidade); + var extra = address.Extra; + Assert.NotNull(extra); + Assert.Equal("01000000", extra["cep_pai"].GetString()); + } + + [Fact] + public async Task AMissingCepThrows() + { + Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound)); + + var failure = await Assert.ThrowsAsync( + () => Client.LookupAsync("99999999")); + + Assert.Contains("99999999", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A404IsNeverRetried() + { + Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound)); + + await Assert.ThrowsAsync(() => Client.LookupAsync("99999999")); + + Assert.Equal(1, Server.Count("/99999999")); + } + + [Fact] + public async Task A400CarriesTheApiMessage() + { + Server.Respond("/00000000", Reply.Of(400, Fixtures.InvalidCep)); + + var failure = await Assert.ThrowsAsync( + () => Client.LookupAsync("00000000")); + + Assert.Equal("CEP deve ter 8 dígitos", failure.Message); + Assert.Equal(1, Server.Count("/00000000")); + } + + [Fact] + public async Task AMalformedCepCostsNoRequest() + { + await Assert.ThrowsAsync(() => Client.LookupAsync("1234567")); + + Assert.Empty(Server.Calls); + } + + [Fact] + public async Task AnUnintelligibleBodyIsDiagnosed() + { + Server.Respond("/01001000", Reply.Of(200, "captive portal")); + + await Assert.ThrowsAsync(() => Client.LookupAsync("01001000")); + } + + [Fact] + public async Task AJsonBodyThatIsNotAnObjectIsDiagnosed() + { + Server.Respond("/01001000", Reply.Of(200, "null")); + + await Assert.ThrowsAsync(() => Client.LookupAsync("01001000")); + } + + [Fact] + public async Task ACancelledCallDoesNotBecomeANetworkError() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => Client.LookupAsync("01001000", cancellation.Token)); + } +} diff --git a/tests/Cepis.Tests/ModelTests.cs b/tests/Cepis.Tests/ModelTests.cs new file mode 100644 index 0000000..7d9d0d6 --- /dev/null +++ b/tests/Cepis.Tests/ModelTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json; + +namespace Cepis.Tests; + +/// The data objects on their own, with no server involved. +public sealed class ModelTests +{ + [Fact] + public void EveryFieldDefaultsToTheEmptyString() + { + var address = new Address(); + + Assert.Equal("", address.Cep); + Assert.Equal("", address.Logradouro); + Assert.Equal("", address.Siafi); + Assert.Null(address.Geo); + } + + [Fact] + public void FormattedCepAddsTheMask() => + Assert.Equal("01001-000", new Address { Cep = "01001000" }.FormattedCep); + + [Fact] + public void FormattedCepLeavesAnythingElseAlone() => + Assert.Equal("123", new Address { Cep = "123" }.FormattedCep); + + [Fact] + public void TwoAddressesForTheSamePlaceAreEqual() + { + var one = new Address { Cep = "01001000", Localidade = "São Paulo" }; + var other = new Address { Cep = "01001000", Localidade = "São Paulo" }; + + Assert.Equal(one, other); + Assert.Equal(one.GetHashCode(), other.GetHashCode()); + } + + [Fact] + public void EqualityIgnoresFieldsThisLibraryDoesNotModel() + { + var plain = new Address { Cep = "01001000" }; + var enriched = plain with + { + Extra = new Dictionary(StringComparer.Ordinal) + { + ["cep_pai"] = JsonDocument.Parse("\"01000000\"").RootElement, + }, + }; + + Assert.Equal(plain, enriched); + Assert.DoesNotContain("cep_pai", enriched.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void TheCoordinateIsPreciseOnlyForAStreet() + { + Assert.True(new Geo { Precisao = "logradouro" }.IsPrecise); + Assert.False(new Geo { Precisao = "localidade" }.IsPrecise); + Assert.False(new Geo().IsPrecise); + } + + [Fact] + public void HealthIsOkOnlyForTheDocumentedStatus() + { + Assert.True(new Health { Status = "ok" }.Ok); + Assert.False(new Health { Status = "degradado" }.Ok); + Assert.False(new Health().Ok); + } +} diff --git a/tests/Cepis.Tests/NormalizeTests.cs b/tests/Cepis.Tests/NormalizeTests.cs new file mode 100644 index 0000000..3ff60cb --- /dev/null +++ b/tests/Cepis.Tests/NormalizeTests.cs @@ -0,0 +1,35 @@ +namespace Cepis.Tests; + +/// Local validation, which happens before any request. +public sealed class NormalizeTests +{ + [Theory] + [InlineData("01001000", "01001000")] + [InlineData("01001-000", "01001000")] + [InlineData(" 01001-000 ", "01001000")] + [InlineData("01.001-000", "01001000")] + [InlineData("01 001 000", "01001000")] + public void AcceptsTheMask(string input, string expected) => + Assert.Equal(expected, CepisClient.NormalizeCep(input)); + + [Theory] + [InlineData("")] + [InlineData("0100100")] + [InlineData("010010000")] + [InlineData("0100100a")] + [InlineData("abcdefgh")] + [InlineData("01001-00")] + public void RejectsAnythingElse(string input) => + Assert.Throws(() => CepisClient.NormalizeCep(input)); + + [Fact] + public void RejectsNull() => + Assert.Throws(() => CepisClient.NormalizeCep(null!)); + + [Fact] + public void TheMessageCarriesWhatWasGiven() + { + var failure = Assert.Throws(() => CepisClient.NormalizeCep("123")); + Assert.Contains("123", failure.Message, StringComparison.Ordinal); + } +} diff --git a/tests/Cepis.Tests/RetryTests.cs b/tests/Cepis.Tests/RetryTests.cs new file mode 100644 index 0000000..2114a12 --- /dev/null +++ b/tests/Cepis.Tests/RetryTests.cs @@ -0,0 +1,249 @@ +namespace Cepis.Tests; + +/// The retry policy: what is retried, how long it waits, what it never retries. +public sealed class RetryTests : HttpTestBase +{ + [Fact] + public async Task ATransientFiveHundredIsRetried() + { + Server.Respond("/01001000", + Reply.Of(500, Fixtures.InternalError), + Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Equal("São Paulo", address.Localidade); + Assert.Equal(2, Server.Count("/01001000")); + } + + [Theory] + [InlineData(500)] + [InlineData(502)] + [InlineData(503)] + [InlineData(504)] + public async Task EveryTransientStatusIsRetried(int status) + { + Server.Respond("/01001000", Reply.Of(status, Fixtures.InternalError), Reply.Of(200, Fixtures.SeAddress)); + + await Client.LookupAsync("01001000"); + + Assert.Equal(2, Server.Count("/01001000")); + } + + [Fact] + public async Task ThreeAttemptsIsTheDefault() + { + Server.Respond("/01001000", Reply.Of(503, Fixtures.InternalError)); + + var failure = await Assert.ThrowsAsync( + () => Client.LookupAsync("01001000")); + + Assert.Equal(503, failure.Status); + Assert.Equal(3, Server.Count("/01001000")); + } + + [Fact] + public async Task OneAttemptDisablesRetrying() + { + using var single = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + MaxAttempts = 1, + }); + Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError)); + + await Assert.ThrowsAsync(() => single.LookupAsync("01001000")); + + Assert.Equal(1, Server.Count("/01001000")); + } + + [Fact] + public async Task A429BecomesRateLimitedAndCarriesRetryAfter() + { + Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "7")); + + var failure = await Assert.ThrowsAsync( + () => Client.LookupAsync("01001000")); + + Assert.Equal(TimeSpan.FromSeconds(7), failure.RetryAfter); + } + + [Fact] + public async Task A429WithoutRetryAfterLeavesNothingToRescheduleWith() + { + Server.Respond("/01001000", Reply.Of(429, "{}")); + + var failure = await Assert.ThrowsAsync( + () => Client.LookupAsync("01001000")); + + Assert.Null(failure.RetryAfter); + } + + [Fact] + public void AnAbsurdRetryAfterIsCappedAtBackoffMax() + { + using var capped = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromMilliseconds(100), + }); + + Assert.Equal(TimeSpan.FromMilliseconds(100), capped.Backoff(1, TimeSpan.FromHours(1))); + } + + [Fact] + public void ARetryAfterUnderTheCapIsHonouredAsAsked() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromSeconds(8), + }); + + Assert.Equal(TimeSpan.FromSeconds(2), client.Backoff(1, TimeSpan.FromSeconds(2))); + } + + [Fact] + public void TheBackoffGrowsExponentiallyAndNeverPassesTheCap() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffBase = TimeSpan.FromMilliseconds(100), + BackoffMax = TimeSpan.FromMilliseconds(800), + }); + + for (var attempt = 1; attempt <= 8; attempt++) + { + var ceiling = TimeSpan.FromMilliseconds(Math.Min(100 * Math.Pow(2, attempt - 1), 800)); + for (var draw = 0; draw < 200; draw++) + { + Assert.InRange(client.Backoff(attempt, null), TimeSpan.Zero, ceiling); + } + } + } + + [Fact] + public void TheBackoffIsJitteredRatherThanTheCeiling() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffBase = TimeSpan.FromSeconds(1), + BackoffMax = TimeSpan.FromSeconds(8), + }); + + var draws = Enumerable.Range(0, 50).Select(_ => client.Backoff(3, null)).Distinct().Count(); + + Assert.True(draws > 1, $"full jitter draws a random wait, got {draws} distinct value(s)"); + } + + [Fact] + public async Task AnAbsurdRetryAfterDoesNotParkTheCaller() + { + using var capped = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromMilliseconds(50), + }); + Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "3600")); + + var failure = await Assert.ThrowsAsync( + () => capped.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); + + Assert.Equal(TimeSpan.FromSeconds(3600), failure.RetryAfter); + Assert.Equal(3, Server.Count("/01001000")); + } + + [Theory] + [InlineData("120", 120)] + [InlineData(" 120 ", 120)] + [InlineData("0", 0)] + public void RetryAfterInSeconds(string header, int expected) => + Assert.Equal(TimeSpan.FromSeconds(expected), CepisClient.ParseRetryAfter(header)); + + [Fact] + public void RetryAfterAsAnHttpDate() + { + var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 2099 07:28:00 GMT"); + + Assert.NotNull(parsed); + Assert.True(parsed > TimeSpan.Zero); + } + + [Fact] + public void AnHttpDateInThePastIsZeroRatherThanNegative() + { + var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 1998 07:28:00 GMT"); + + Assert.Equal(TimeSpan.Zero, parsed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a date at all")] + public void NonsenseRetryAfterIsIgnored(string? header) => + Assert.Null(CepisClient.ParseRetryAfter(header)); + + [Fact] + public async Task NothingListeningIsANetworkError() + { + using var unreachable = new CepisClient(new CepisClientOptions + { + BaseUrl = "http://127.0.0.1:1", + MaxAttempts = 2, + BackoffBase = TimeSpan.FromMilliseconds(1), + BackoffMax = TimeSpan.FromMilliseconds(5), + Timeout = TimeSpan.FromSeconds(30), + }); + + await Assert.ThrowsAsync(() => unreachable.LookupAsync("01001000")); + } + + [Fact] + public async Task TheTotalTimeoutStopsTheCallShortOfItsAttempts() + { + const int budgetedAttempts = 200; + using var bounded = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromSeconds(30), + MaxAttempts = budgetedAttempts, + BackoffBase = TimeSpan.FromMilliseconds(100), + BackoffMax = TimeSpan.FromMilliseconds(100), + TotalTimeout = TimeSpan.FromSeconds(3), + }); + Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError)); + + var failure = await Assert.ThrowsAsync( + () => bounded.LookupAsync("01001000")); + + var attempts = Server.Count("/01001000"); + Assert.Equal(500, failure.Status); + Assert.InRange(attempts, 1, budgetedAttempts - 1); + } + + [Fact] + public async Task TheTotalTimeoutEndsACallThatNeverGetsAnAnswer() + { + using var bounded = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromMinutes(1), + MaxAttempts = 3, + TotalTimeout = TimeSpan.FromMilliseconds(500), + }); + Server.Handle(_ => + { + Thread.Sleep(TimeSpan.FromSeconds(10)); + return Reply.Of(200, Fixtures.SeAddress); + }); + + await Assert.ThrowsAsync( + () => bounded.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); + + Assert.Equal(1, Server.Count("/01001000")); + } +} -- cgit v1.3