summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:42:22 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:42:22 -0300
commitb6f876f8b0847c223f3310d3e5208d78982a13c1 (patch)
treefce85d75a2e491b6514a4ed7c42dca898f944cc5
parentcf5597871327dbff2c7c743672d696e7ff340c42 (diff)
downloadpascal-0.2.0.tar.gz
Carry the municipality range on a not-found CEPHEADv0.2.0main
A CEP that fails is rarely a random number: it is usually someone who knows the city and guessed the last digits, and the classic guess is the -000 that 957 municipalities never received. The API now answers those 404s with a faixa block naming the municipality the number would have belonged to, which is enough to tell the user "no such CEP in Areal" instead of just "not found". It stays an error. ECepisNotFound carries the block as a TCepisFaixa and owns it, the same way an address owns its geo, so no shape of the response can turn a not-found into an address. Parsing is tolerant on purpose: a faixa that is absent, null, empty or unreadable leaves Faixa nil rather than costing the caller the clean not-found underneath. CepIni and CepFim are the first and the last CEP that actually exist in the municipality - not the bounds the Correios publish, whose start for Areal is the very CEP that just failed - and the range between them is sparse. That is what Ceps is for, and why the comments insist the pair is not a list.
-rw-r--r--README.md49
-rw-r--r--src/Cepis.pas232
-rw-r--r--tests/TestCepis.pas203
3 files changed, 476 insertions, 8 deletions
diff --git a/README.md b/README.md
index 3129bd8..2b1a652 100644
--- a/README.md
+++ b/README.md
@@ -62,10 +62,12 @@ exceptions:
| `TCepisAddressList` from `LookupMany` | you — and freeing it frees the addresses inside |
| `TCepisHealth` from `Health` | you |
| `TCepisGeo` on an address | its address |
+| `TCepisFaixa` on an `ECepisNotFound` | its exception, when the handler ends |
| the client behind `CepisLookup` and friends | the `Cepis` unit, at finalisation |
-`TCepisAddressList.Extract` and `TCepisAddress.ExtractGeo` hand ownership over
-when you need one item to outlive its container.
+`TCepisAddressList.Extract`, `TCepisAddress.ExtractGeo` and
+`ECepisNotFound.ExtractFaixa` hand ownership over when you need one item to
+outlive its container.
## Usage
@@ -218,7 +220,7 @@ have to make**, not by the HTTP status that caused it:
| error | when | does retrying help? |
| --- | --- | --- |
| `ECepisInvalidCEP` | not 8 digits | no |
-| `ECepisNotFound` | well-formed, not in the database | no |
+| `ECepisNotFound` | well-formed, not in the database; may carry `Faixa` | no |
| `ECepisRateLimited` | 429; carries `RetryAfter` | yes, later |
| `ECepisServerError` | 5xx; carries `Status` | yes, later |
| `ECepisNetworkError` | DNS, connection, timeout, TLS | yes, later |
@@ -235,6 +237,47 @@ except
end;
```
+### A not-found that says where the CEP would be
+
+A CEP that fails is rarely a random number: it is usually someone who knows the
+city and guessed the last digits, and the classic guess is the `-000` that 957
+municipalities never received. When the API can place the failed number inside
+a municipality's band, the 404 says which one, and `ECepisNotFound` carries it
+as a `TCepisFaixa`:
+
+```pascal
+try
+ Address := CepisLookup('25845-000');
+except
+ on E: ECepisNotFound do
+ if E.HasFaixa then
+ WriteLn('No such CEP in ', E.Faixa.Localidade, '/', E.Faixa.UF,
+ ' — it has ', E.Faixa.Ceps, ' CEPs, from ',
+ E.Faixa.CepIni, ' to ', E.Faixa.CepFim)
+ // No such CEP in Areal/RJ — it has 118 CEPs, from 25845001 to 25849899
+ else
+ WriteLn('No such CEP');
+end;
+```
+
+**It is still a 404.** The faixa is context for an error, not an address: it
+names the municipality the number *would* belong to, and the CEP asked for does
+not exist. `E.Faixa` is `nil` — and `E.HasFaixa` is `False` — whenever the
+number falls in a band no municipality owns; the API omits the field there
+rather than sending `null`, and a malformed one is dropped the same way. The
+exception owns the faixa and frees it when the handler ends; `E.ExtractFaixa`
+takes it over if you need it to outlive the `except` block.
+
+Two traps in the pair of bounds:
+
+- **They are not the published range.** `CepIni` and `CepFim` are the smallest
+ and the largest CEP that *actually exist* in the municipality. The DNE's own
+ start for Areal is `25845000` — exactly the CEP that just failed — while
+ `CepIni` is `25845001`.
+- **The range is sparse.** Areal's 118 CEPs are scattered over the ~4,900
+ numbers between `25845001` and `25849899`. Never treat the pair as a list of
+ valid CEPs, and never count from it: that is what `Ceps` is for.
+
`CepisNormalizeCEP` strips the mask and validates the 8 digits on its own, for
code that wants to check before it asks:
diff --git a/src/Cepis.pas b/src/Cepis.pas
index 7b628e9..cf62d08 100644
--- a/src/Cepis.pas
+++ b/src/Cepis.pas
@@ -16,7 +16,8 @@
Ownership, in one sentence: every object this unit hands back belongs to the
caller, who frees it - except the geo block, which belongs to its address,
- and the default client, which belongs to this unit.
+ the faixa block, which belongs to the not-found error carrying it, and the
+ default client, which belongs to this unit.
Note on field names: the API surface is English, but the data fields keep the
API's own Portuguese names (Logradouro, Bairro, Localidade). What you read in
@@ -36,7 +37,7 @@ uses
const
{ Version of this library, as it appears in the default User-Agent. }
- CepisVersion = '0.1.0';
+ CepisVersion = '0.2.0';
{ Where the API lives when nothing else is configured. }
CepisDefaultURL = 'https://cep.is';
@@ -45,6 +46,9 @@ const
CepisMaxBatch = 500;
type
+ { Declared in full below. ECepisNotFound carries one. }
+ TCepisFaixa = class;
+
{
Base of every error this unit raises. Catch it to handle any failure.
@@ -71,8 +75,48 @@ type
{
The CEP is well formed but does not exist in the DNE database.
+
+ Faixa, when it is there, names the municipality whose CEP band the number
+ falls into - enough to answer "that CEP does not exist in Areal" instead of
+ just "not found". It is context for the failure and nothing more: the CEP
+ asked for still does not exist, and a faixa never turns this into an
+ address.
+
+ It is nil when the number falls in a band no municipality owns, and nil
+ against an API that does not send the field at all. The API omits it rather
+ than sending null, so there is no third state to handle. HasFaixa is the
+ shorthand for the check.
}
- ECepisNotFound = class(ECepis);
+ ECepisNotFound = class(ECepis)
+ private
+ FFaixa: TCepisFaixa;
+ function GetHasFaixa: Boolean;
+ public
+ { Takes ownership of AFaixa, which may be nil. }
+ constructor Create(const AMessage: string; AFaixa: TCepisFaixa);
+ reintroduce;
+
+ { Frees the faixa, if any. }
+ destructor Destroy; override;
+
+ {
+ Hand the faixa over to the caller, who becomes responsible for freeing
+ it. Faixa is nil afterwards. Returns nil when there was none.
+
+ The exception itself is freed when the except block ends, so this is the
+ way to let the faixa outlive the handler.
+ }
+ function ExtractFaixa: TCepisFaixa;
+
+ { True when the API placed the CEP inside a municipality's band. }
+ property HasFaixa: Boolean read GetHasFaixa;
+
+ {
+ The municipality the CEP would belong to, or nil. Owned by this
+ exception; see ExtractFaixa.
+ }
+ property Faixa: TCepisFaixa read FFaixa;
+ end;
{
The request never completed: DNS, refused connection, timeout, TLS.
@@ -174,6 +218,73 @@ type
end;
{
+ The municipality a CEP that does not exist would have belonged to.
+
+ Attached to ECepisNotFound when the failed number falls inside the band the
+ Correios allocated to one municipality. The lookup still failed - this is
+ context for the error, never an address.
+
+ Why the API bothers: a CEP that fails is rarely a random number. It is
+ usually someone who knows the city and guessed the last digits, and the
+ classic guess is the '-000' that 957 municipalities never received.
+
+ CepIni and CepFim are the smallest and the largest CEP that really exist
+ there, not the bounds the Correios publish. Areal's DNE band opens at
+ 25845000 - which is exactly the CEP that just failed - while its CepIni is
+ 25845001.
+
+ The pair is not a list. The range is sparse: Areal's 118 CEPs are scattered
+ across the ~4,900 numbers between 25845001 and 25849899, so iterating from
+ one bound to the other yields forty times more numbers than exist. Ceps is
+ how many there are, and it is there precisely so the gap cannot pass
+ unnoticed.
+
+ Owned by the ECepisNotFound that carries it, and freed with it. Use
+ ECepisNotFound.ExtractFaixa to take ownership.
+ }
+ TCepisFaixa = class(TPersistent)
+ private
+ FLocalidade: string;
+ FUF: string;
+ FEstado: string;
+ FRegiao: string;
+ FIbge: string;
+ FCepIni: string;
+ FCepFim: string;
+ FCeps: Int64;
+ public
+ constructor Create(const ALocalidade, AUF, AEstado, ARegiao, AIbge,
+ ACepIni, ACepFim: string; ACeps: Int64);
+ published
+ { Municipality that owns the band, e.g. 'Areal'. }
+ property Localidade: string read FLocalidade;
+
+ { Two-letter state code, e.g. 'RJ'. }
+ property UF: string read FUF;
+
+ { State name in full, e.g. 'Rio de Janeiro'. }
+ property Estado: string read FEstado;
+
+ { Region of the country, e.g. 'Sudeste'. }
+ property Regiao: string read FRegiao;
+
+ { IBGE municipality code - the key to join against census data. }
+ property Ibge: string read FIbge;
+
+ { Smallest CEP that exists in the municipality, unmasked. }
+ property CepIni: string read FCepIni;
+
+ { Largest CEP that exists in the municipality, unmasked. }
+ property CepFim: string read FCepFim;
+
+ {
+ How many CEPs exist between CepIni and CepFim, both included. Far fewer
+ than the arithmetic distance between them - see the note above.
+ }
+ property Ceps: Int64 read FCeps;
+ end;
+
+ {
A CEP and everything the database knows about it.
Fields the database does not have for that CEP come back as empty strings,
@@ -398,6 +509,10 @@ type
database; ECepisRateLimited, ECepisServerError or ECepisNetworkError once
the attempts are exhausted; ECepisInvalidResponse when a success status
carries a body that is not a JSON object.
+
+ The ECepisNotFound may carry a TCepisFaixa naming the municipality the
+ CEP would have belonged to. Read it there; a not-found is still a
+ not-found.
}
function Lookup(const ACep: string): TCepisAddress;
@@ -783,6 +898,76 @@ begin
Result := TCepisGeo.Create(Lat, Lon, RaioM, Precisao);
end;
+{
+ Read the faixa block of a 404 body, or nil when there is none to read.
+
+ Deliberately tolerant. A body without 'faixa', with a null or a string
+ there, with an empty object, or with nothing that names a municipality all
+ give nil - which is the same thing the caller sees against an API that never
+ sends the field. A field that is there but unreadable falls back to empty,
+ or to 0 for Ceps, rather than throwing the municipality away with it.
+
+ Nothing here raises and nothing here leaks: a faixa is a bonus on top of an
+ error, and no shape of it may cost the caller the clean not-found underneath.
+}
+function FaixaFromJSON(AObject: TJSONObject): TCepisFaixa;
+var
+ Block: TJSONData;
+ Faixa: TJSONObject;
+ Localidade: string;
+begin
+ Result := nil;
+ if AObject = nil then
+ Exit;
+ Block := AObject.Find('faixa');
+ if not (Block is TJSONObject) then
+ Exit;
+ Faixa := TJSONObject(Block);
+ Localidade := JSONString(Faixa, 'localidade');
+ if Localidade = '' then
+ Exit;
+ Result := TCepisFaixa.Create(Localidade,
+ JSONString(Faixa, 'uf'),
+ JSONString(Faixa, 'estado'),
+ JSONString(Faixa, 'regiao'),
+ JSONString(Faixa, 'ibge'),
+ JSONString(Faixa, 'cep_ini'),
+ JSONString(Faixa, 'cep_fim'),
+ JSONInt64(Faixa, 'ceps'));
+end;
+
+{
+ Build the ECepisNotFound for a 404, faixa included when the body carries one.
+
+ The exception is returned rather than raised so the faixa has exactly one
+ owner from the moment it exists.
+}
+function NotFoundFor(const ANumber, ABody: string): ECepisNotFound;
+var
+ Data: TJSONData;
+ Faixa: TCepisFaixa;
+ Text: string;
+begin
+ Faixa := nil;
+ Data := ParseJSONLoose(ABody);
+ try
+ if Data is TJSONObject then
+ Faixa := FaixaFromJSON(TJSONObject(Data));
+ finally
+ Data.Free;
+ end;
+
+ Text := 'CEP ' + ANumber + ' does not exist in the DNE database';
+ if Faixa <> nil then
+ begin
+ Text := Text + ' - it falls in the CEP range of ' + Faixa.Localidade;
+ if Faixa.UF <> '' then
+ Text := Text + '/' + Faixa.UF;
+ end;
+
+ Result := ECepisNotFound.Create(Text, Faixa);
+end;
+
function AddressFromJSON(AObject: TJSONObject; const ARaw: string): TCepisAddress;
begin
Result := TCepisAddress.Create;
@@ -837,6 +1022,44 @@ begin
FStatus := AStatus;
end;
+constructor ECepisNotFound.Create(const AMessage: string;
+ AFaixa: TCepisFaixa);
+begin
+ inherited Create(AMessage);
+ FFaixa := AFaixa;
+end;
+
+destructor ECepisNotFound.Destroy;
+begin
+ FreeAndNil(FFaixa);
+ inherited Destroy;
+end;
+
+function ECepisNotFound.ExtractFaixa: TCepisFaixa;
+begin
+ Result := FFaixa;
+ FFaixa := nil;
+end;
+
+function ECepisNotFound.GetHasFaixa: Boolean;
+begin
+ Result := FFaixa <> nil;
+end;
+
+constructor TCepisFaixa.Create(const ALocalidade, AUF, AEstado, ARegiao,
+ AIbge, ACepIni, ACepFim: string; ACeps: Int64);
+begin
+ inherited Create;
+ FLocalidade := ALocalidade;
+ FUF := AUF;
+ FEstado := AEstado;
+ FRegiao := ARegiao;
+ FIbge := AIbge;
+ FCepIni := ACepIni;
+ FCepFim := ACepFim;
+ FCeps := ACeps;
+end;
+
constructor TCepisGeo.Create(ALat, ALon: Double; ARaioM: Integer;
const APrecisao: string);
begin
@@ -1072,8 +1295,7 @@ begin
Request('/' + Number, 0, Status, Body);
if Status = 404 then
- raise ECepisNotFound.Create('CEP ' + Number +
- ' does not exist in the DNE database');
+ raise NotFoundFor(Number, Body);
if Status = 400 then
raise ECepisInvalidCEP.Create(ErrorMessageOf(Body,
'CEP ' + Number + ' rejected'));
diff --git a/tests/TestCepis.pas b/tests/TestCepis.pas
index 75a57ee..73c8639 100644
--- a/tests/TestCepis.pas
+++ b/tests/TestCepis.pas
@@ -34,6 +34,17 @@ const
SaoPaulo = 'S' + #$C3#$A3 + 'o Paulo';
PracaDaSe = 'Pra' + #$C3#$A7 + 'a da S' + #$C3#$A9;
+ {
+ A 404 as the API sends it for 25845-000: the CEP the Correios list as the
+ start of Areal's band and never allocated. The faixa places it, the status
+ still says the CEP does not exist.
+ }
+ ArealNotFound =
+ '{"erro":"cep_nao_encontrado","mensagem":"CEP n' + #$C3#$A3 +
+ 'o existe na base do DNE","faixa":{"localidade":"Areal","uf":"RJ",' +
+ '"estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3300225",' +
+ '"cep_ini":"25845001","cep_fim":"25849899","ceps":118}}';
+
CsvHeader = 'cep,logradouro,complemento,unidade,bairro,localidade,uf,' +
'estado,regiao,ibge,gia,ddd,siafi';
@@ -432,6 +443,10 @@ type
procedure NewApiFieldDoesNotBreak;
procedure NotFound;
procedure NotFoundIsNotRetried;
+ procedure NotFoundCarriesTheFaixa;
+ procedure NotFoundWithoutFaixaHasNone;
+ procedure UnusableFaixaStillLeavesACleanNotFound;
+ procedure FaixaKeepsTheMunicipalityWhenTheCountIsGarbage;
procedure InvalidCepNeverLeavesTheMachine;
procedure Api400BecomesInvalidCep;
procedure ResponseThatIsNotJson;
@@ -559,6 +574,163 @@ begin
AssertEquals(1, FServer.CallCount('/99999999'));
end;
+procedure TTestLookup.NotFoundCarriesTheFaixa;
+var
+ Address: TCepisAddress;
+ Raised, HasFaixa: Boolean;
+ Localidade, UF, Estado, Regiao, Ibge, CepIni, CepFim, Message: string;
+ Ceps: Int64;
+begin
+ FServer.Respond('/25845000', 404, ArealNotFound);
+ Raised := False;
+ HasFaixa := False;
+ Localidade := '';
+ UF := '';
+ Estado := '';
+ Regiao := '';
+ Ibge := '';
+ CepIni := '';
+ CepFim := '';
+ Message := '';
+ Ceps := 0;
+ Address := nil;
+ try
+ Address := FClient.Lookup('25845-000');
+ except
+ on E: ECepisNotFound do
+ begin
+ Raised := True;
+ Message := E.Message;
+ HasFaixa := E.HasFaixa;
+ if E.Faixa <> nil then
+ begin
+ Localidade := E.Faixa.Localidade;
+ UF := E.Faixa.UF;
+ Estado := E.Faixa.Estado;
+ Regiao := E.Faixa.Regiao;
+ Ibge := E.Faixa.Ibge;
+ CepIni := E.Faixa.CepIni;
+ CepFim := E.Faixa.CepFim;
+ Ceps := E.Faixa.Ceps;
+ end;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('a faixa must not turn a not-found into an address', Raised);
+ AssertTrue('the faixa must be read', HasFaixa);
+ AssertEquals('Areal', Localidade);
+ AssertEquals('RJ', UF);
+ AssertEquals('Rio de Janeiro', Estado);
+ AssertEquals('Sudeste', Regiao);
+ AssertEquals('3300225', Ibge);
+ AssertTrue('cep_ini is the first CEP that exists, not the published bound',
+ CepIni = '25845001');
+ AssertEquals('25849899', CepFim);
+ AssertEquals(118, Ceps);
+ AssertTrue('the message must name the municipality',
+ Pos('Areal', Message) > 0);
+ AssertTrue('the message must still name the CEP',
+ Pos('25845000', Message) > 0);
+end;
+
+procedure TTestLookup.NotFoundWithoutFaixaHasNone;
+var
+ Address: TCepisAddress;
+ Raised, HasFaixa, FaixaIsNil: Boolean;
+begin
+ FServer.Respond('/99999999', 404,
+ '{"erro":"cep_nao_encontrado","mensagem":"CEP n' + #$C3#$A3 +
+ 'o existe na base do DNE"}');
+ Raised := False;
+ HasFaixa := True;
+ FaixaIsNil := False;
+ Address := nil;
+ try
+ Address := FClient.Lookup('99999999');
+ except
+ on E: ECepisNotFound do
+ begin
+ Raised := True;
+ HasFaixa := E.HasFaixa;
+ FaixaIsNil := E.Faixa = nil;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisNotFound', Raised);
+ AssertFalse('a CEP in nobody''s band carries no faixa', HasFaixa);
+ AssertTrue('absent means nil, never an empty faixa', FaixaIsNil);
+end;
+
+procedure TTestLookup.UnusableFaixaStillLeavesACleanNotFound;
+const
+ Bodies: array[0..6] of string = (
+ '{"erro":"cep_nao_encontrado","faixa":null}',
+ '{"erro":"cep_nao_encontrado","faixa":{}}',
+ '{"erro":"cep_nao_encontrado","faixa":"Areal"}',
+ '{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}',
+ '{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ","ceps":118}}',
+ '{"erro":"cep_nao_encontrado","faixa":{"localidade":""}}',
+ '{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal"'
+ );
+var
+ Address: TCepisAddress;
+ Raised, HasFaixa: Boolean;
+ I: Integer;
+begin
+ for I := Low(Bodies) to High(Bodies) do
+ begin
+ FServer.Respond('/25845000', 404, Bodies[I]);
+ Raised := False;
+ HasFaixa := True;
+ Address := nil;
+ try
+ Address := FClient.Lookup('25845000');
+ except
+ on E: ECepisNotFound do
+ begin
+ Raised := True;
+ HasFaixa := E.HasFaixa;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('body ' + IntToStr(I) + ' must still be a not-found', Raised);
+ AssertFalse('body ' + IntToStr(I) + ' must carry no faixa', HasFaixa);
+ end;
+end;
+
+procedure TTestLookup.FaixaKeepsTheMunicipalityWhenTheCountIsGarbage;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ Localidade: string;
+ Ceps: Int64;
+begin
+ FServer.Respond('/25845000', 404,
+ '{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ",' +
+ '"ceps":"muitos"}}');
+ Raised := False;
+ Localidade := '';
+ Ceps := -1;
+ Address := nil;
+ try
+ Address := FClient.Lookup('25845000');
+ except
+ on E: ECepisNotFound do
+ begin
+ Raised := True;
+ if E.Faixa <> nil then
+ begin
+ Localidade := E.Faixa.Localidade;
+ Ceps := E.Faixa.Ceps;
+ end;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisNotFound', Raised);
+ AssertEquals('Areal', Localidade);
+ AssertEquals('an unreadable count falls back to 0', 0, Ceps);
+end;
+
procedure TTestLookup.InvalidCepNeverLeavesTheMachine;
var
Address: TCepisAddress;
@@ -1097,6 +1269,7 @@ type
published
procedure ExtractKeepsAnAddressAlive;
procedure ExtractGeoTransfersTheCoordinate;
+ procedure ExtractFaixaOutlivesTheError;
end;
procedure TTestOwnership.ExtractKeepsAnAddressAlive;
@@ -1137,6 +1310,36 @@ begin
end;
end;
+procedure TTestOwnership.ExtractFaixaOutlivesTheError;
+var
+ Address: TCepisAddress;
+ Faixa: TCepisFaixa;
+ StillOwned: Boolean;
+begin
+ FServer.Respond('/25845000', 404, ArealNotFound);
+ Faixa := nil;
+ StillOwned := True;
+ Address := nil;
+ try
+ Address := FClient.Lookup('25845000');
+ except
+ on E: ECepisNotFound do
+ begin
+ Faixa := E.ExtractFaixa;
+ StillOwned := E.HasFaixa;
+ end;
+ end;
+ Address.Free;
+ try
+ AssertFalse('the error no longer owns it', StillOwned);
+ AssertTrue('the faixa survives the handler', Faixa <> nil);
+ AssertEquals('Areal', Faixa.Localidade);
+ AssertEquals(118, Faixa.Ceps);
+ finally
+ Faixa.Free;
+ end;
+end;
+
var
Runner: TTestRunner;