summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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;