diff options
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | src/Cepis.pas | 232 | ||||
| -rw-r--r-- | tests/TestCepis.pas | 203 |
3 files changed, 476 insertions, 8 deletions
@@ -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; |