diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/Cepis.pm | 17 | ||||
| -rw-r--r-- | lib/Cepis/Address.pm | 2 | ||||
| -rw-r--r-- | lib/Cepis/CSV.pm | 2 | ||||
| -rw-r--r-- | lib/Cepis/Client.pm | 44 | ||||
| -rw-r--r-- | lib/Cepis/Error.pm | 37 | ||||
| -rw-r--r-- | lib/Cepis/Faixa.pm | 196 | ||||
| -rw-r--r-- | lib/Cepis/Geo.pm | 2 | ||||
| -rw-r--r-- | lib/Cepis/Health.pm | 2 |
8 files changed, 284 insertions, 18 deletions
diff --git a/lib/Cepis.pm b/lib/Cepis.pm index 224d80d..1ea5b00 100644 --- a/lib/Cepis.pm +++ b/lib/Cepis.pm @@ -8,10 +8,11 @@ use Exporter 5.57 qw(import); use Cepis::Address; use Cepis::Client; use Cepis::Error; +use Cepis::Faixa; use Cepis::Geo; use Cepis::Health; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; our @EXPORT_OK = qw(lookup lookup_many health normalize_cep); @@ -101,6 +102,18 @@ the lot, and the split below it is by whether retrying helps: } } +A C<CEPNotFound> often knows B<where> the CEP would have been. When the API says +so, C<< $err->faixa >> is a L<Cepis::Faixa> naming the municipality that owns +that band, and the message names it too: + + # "CEP 25845000 does not exist in the DNE database (it would fall in Areal/RJ)" + +It is still a failure — C<faixa> is context for the error, never an address — +and it is C<undef> when the CEP fell in a band no municipality owns. It is worth +checking because a failing CEP is rarely random: it is usually someone who knows +the city and guessed the number, and the classic guess is the C<-000> that 957 +municipalities never received. + =head2 Field names The API surface is English, but the B<data fields keep the API's own Portuguese @@ -169,7 +182,7 @@ The L<Cepis::Client> behind the functions above, built on first use. =head1 SEE ALSO L<Cepis::Client>, L<Cepis::Address>, L<Cepis::Geo>, L<Cepis::Health>, -L<Cepis::Error> +L<Cepis::Error>, L<Cepis::Faixa> The API itself is documented at L<https://cep.is>. diff --git a/lib/Cepis/Address.pm b/lib/Cepis/Address.pm index a938d6f..d1ae21f 100644 --- a/lib/Cepis/Address.pm +++ b/lib/Cepis/Address.pm @@ -5,7 +5,7 @@ use warnings; use Cepis::Geo; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; our @FIELDS = qw( cep logradouro complemento unidade bairro localidade diff --git a/lib/Cepis/CSV.pm b/lib/Cepis/CSV.pm index 6fabc0c..7ef6880 100644 --- a/lib/Cepis/CSV.pm +++ b/lib/Cepis/CSV.pm @@ -3,7 +3,7 @@ package Cepis::CSV; use strict; use warnings; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub parse_rows { my ($text) = @_; diff --git a/lib/Cepis/Client.pm b/lib/Cepis/Client.pm index 35eb51b..e75868b 100644 --- a/lib/Cepis/Client.pm +++ b/lib/Cepis/Client.pm @@ -13,9 +13,10 @@ use Time::HiRes (); use Cepis::Address; use Cepis::CSV; use Cepis::Error; +use Cepis::Faixa; use Cepis::Health; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; use constant DEFAULT_URL => 'https://cep.is'; use constant MAX_BATCH => 500; @@ -120,8 +121,10 @@ sub lookup { my ( $status, $body ) = $self->_request("/$number"); if ( $status == 404 ) { - Cepis::Error::CEPNotFound->throw( - message => "CEP $number does not exist in the DNE database" ); + my $faixa = _faixa($body); + my $message = "CEP $number does not exist in the DNE database"; + $message .= ' (it would fall in ' . $faixa->label . ')' if $faixa; + Cepis::Error::CEPNotFound->throw( message => $message, faixa => $faixa ); } if ( $status == 400 ) { Cepis::Error::InvalidCEP->throw( @@ -394,6 +397,14 @@ sub _error_message { return "$data->{mensagem}"; } +sub _faixa { + my ($body) = @_; + + my $data = eval { $JSON->decode( defined $body ? $body : '' ) }; + return undef unless ref $data eq 'HASH'; + return Cepis::Faixa->from_json( $data->{faixa} ); +} + sub _decode { my ($body) = @_; return '' unless defined $body; @@ -575,6 +586,10 @@ A 400 from the API is surfaced as L<Cepis::Error::InvalidCEP> carrying the API's own message: the format already passed local validation, so the server knows something this client does not. +A 404 becomes L<Cepis::Error::CEPNotFound>, and that error may carry a +L<Cepis::Faixa> in C<< $err->faixa >> naming the municipality the CEP would have +belonged to — see L</"A not-found that knows where it was">. + Throws L<Cepis::Error::InvalidCEP> when the CEP is not 8 digits (no request is made), L<Cepis::Error::CEPNotFound> when it is well-formed but absent from the database, L<Cepis::Error::RateLimited> on 429 after every attempt, @@ -583,6 +598,27 @@ L<Cepis::Error::Network> when the request never completed, and L<Cepis::Error::InvalidResponse> when a success status carries an unreadable body. +=head3 A not-found that knows where it was + + my $address = eval { $client->lookup('25845000') }; + if ( !$address && ref $@ && $@->isa('Cepis::Error::CEPNotFound') ) { + if ( my $faixa = $@->faixa ) { + printf "%s does not exist in %s (%d CEPs, %s..%s)\n", + '25845000', $faixa->label, $faixa->ceps, + $faixa->cep_ini, $faixa->cep_fim; + } + } + +The API's 404 body may carry a C<faixa> block naming the municipality that owns +the band the CEP fell into. When it does, the error carries a L<Cepis::Faixa> +and its message names the municipality; when the CEP fell in a band nobody owns, +the API omits the block and C<< $err->faixa >> is C<undef>. + +B<It is still a 404.> Nothing here turns a not-found into an address; the +lookup threw, and C<faixa> only says where the CEP would have been. Nor is +C<cep_ini>..C<cep_fim> a list of valid CEPs — the range is sparse, which is what +C<ceps> is for. L<Cepis::Faixa> spells both out. + =head2 lookup_many my $result = $client->lookup_many( \@ceps ); @@ -643,7 +679,7 @@ C<500>, the most CEPs the API accepts in a single batch request. =head1 SEE ALSO -L<Cepis>, L<Cepis::Address>, L<Cepis::Health>, L<Cepis::Error> +L<Cepis>, L<Cepis::Address>, L<Cepis::Health>, L<Cepis::Error>, L<Cepis::Faixa> =head1 LICENCE diff --git a/lib/Cepis/Error.pm b/lib/Cepis/Error.pm index 080b0a1..37f2a08 100644 --- a/lib/Cepis/Error.pm +++ b/lib/Cepis/Error.pm @@ -3,7 +3,7 @@ package Cepis::Error; use strict; use warnings; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; use overload '""' => sub { $_[0]->message }, @@ -30,7 +30,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; package Cepis::Error::CEPNotFound; @@ -38,7 +38,9 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; + +sub faixa { return $_[0]->{faixa} } package Cepis::Error::Network; @@ -46,7 +48,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; package Cepis::Error::ServerError; @@ -54,7 +56,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub status { return $_[0]->{status} } @@ -64,7 +66,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub retry_after { return $_[0]->{retry_after} } @@ -74,7 +76,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; 1; @@ -171,6 +173,25 @@ message. The CEP is well-formed but does not exist in the DNE database. +=over 4 + +=item C<< $err->faixa >> + +A L<Cepis::Faixa> naming the municipality the CEP I<would> belong to, or +C<undef> when it fell in a band no municipality owns. The API omits the block +rather than sending null, and a malformed one is dropped rather than turning the +not-found into a parse failure. + +It is context for the error, B<not> an address: the lookup still failed. What it +buys is telling the user "25845000 does not exist in Areal/RJ" instead of "not +found" — worth having, because a failing CEP is rarely random. It is usually +someone who knows the city and guessed the number, and the classic guess is the +C<-000> that 957 municipalities never received. + +When it is present, the message already names the municipality. + +=back + =head2 Cepis::Error::Network The request never completed: DNS, refused connection, timeout, TLS. @@ -217,7 +238,7 @@ as a diagnosis: the problem is not in your code. =head1 SEE ALSO -L<Cepis>, L<Cepis::Client> +L<Cepis>, L<Cepis::Client>, L<Cepis::Faixa> =head1 LICENCE diff --git a/lib/Cepis/Faixa.pm b/lib/Cepis/Faixa.pm new file mode 100644 index 0000000..7dabdc3 --- /dev/null +++ b/lib/Cepis/Faixa.pm @@ -0,0 +1,196 @@ +package Cepis::Faixa; + +use strict; +use warnings; + +use Scalar::Util (); + +our $VERSION = '0.2.0'; + +sub from_json { + my ( $class, $data ) = @_; + + return undef unless ref $data eq 'HASH'; + + for my $key (qw(localidade cep_ini cep_fim ceps)) { + return undef unless defined $data->{$key}; + return undef if ref $data->{$key}; + } + return undef unless length "$data->{localidade}"; + for my $key (qw(cep_ini cep_fim)) { + return undef unless "$data->{$key}" =~ /\A[0-9]{8}\z/; + } + return undef unless Scalar::Util::looks_like_number( $data->{ceps} ); + + return bless { + localidade => "$data->{localidade}", + uf => _string( $data->{uf} ), + estado => _string( $data->{estado} ), + regiao => _string( $data->{regiao} ), + ibge => _string( $data->{ibge} ), + cep_ini => "$data->{cep_ini}", + cep_fim => "$data->{cep_fim}", + ceps => int( 0 + $data->{ceps} ), + }, $class; +} + +sub localidade { return $_[0]->{localidade} } +sub uf { return $_[0]->{uf} } +sub estado { return $_[0]->{estado} } +sub regiao { return $_[0]->{regiao} } +sub ibge { return $_[0]->{ibge} } +sub cep_ini { return $_[0]->{cep_ini} } +sub cep_fim { return $_[0]->{cep_fim} } +sub ceps { return $_[0]->{ceps} } + +sub label { + my ($self) = @_; + return length $self->{uf} + ? "$self->{localidade}/$self->{uf}" + : $self->{localidade}; +} + +sub to_hash { + my ($self) = @_; + return { + localidade => $self->{localidade}, + uf => $self->{uf}, + estado => $self->{estado}, + regiao => $self->{regiao}, + ibge => $self->{ibge}, + cep_ini => $self->{cep_ini}, + cep_fim => $self->{cep_fim}, + ceps => $self->{ceps}, + }; +} + +sub _string { + my ($value) = @_; + return '' unless defined $value; + return '' if ref $value; + return "$value"; +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Faixa - the municipality a CEP that does not exist would belong to + +=head1 SYNOPSIS + + my $address = eval { Cepis::lookup('25845000') }; + if ( !$address && ref $@ && $@->isa('Cepis::Error::CEPNotFound') ) { + + if ( my $faixa = $@->faixa ) { + print $faixa->localidade, "\n"; # "Areal" + print $faixa->uf, "\n"; # "RJ" + print $faixa->ceps, "\n"; # 118 + printf "%s..%s\n", $faixa->cep_ini, $faixa->cep_fim; + # 25845001..25849899 + } + } + +=head1 DESCRIPTION + +B<This is context for an error, not an address.> The lookup still failed, the +CEP still does not exist, and L<Cepis::Client/lookup> still throws +L<Cepis::Error::CEPNotFound>. What this object adds is I<which municipality +owns the band the CEP fell into> — enough to tell the user "25845000 does not +exist in Areal/RJ" instead of "not found". + +That matters because a failing CEP is rarely random: it is usually someone who +knows the city and guessed the number, and the classic guess is the C<-000> +that 957 municipalities never received. + +The object is reachable as C<< $err->faixa >> on L<Cepis::Error::CEPNotFound>, +and is C<undef> whenever the API sent no C<faixa> — the API B<omits> the field +rather than sending null when the CEP falls in a band no municipality owns. + +=head2 The range is not a list of valid CEPs + +L</cep_ini> and L</cep_fim> are the B<smallest and the largest CEP that actually +exist> in that municipality. They are not the bounds the Correios publish: the +DNE's own start for Areal is C<25845000> — exactly the CEP that just failed. + +The range is also B<sparse>. Areal's 118 CEPs are scattered across the roughly +4,900 numbers between C<25845001> and C<25849899>. Iterating the interval, or +presenting it as "the valid CEPs for this city", produces mostly numbers that do +not exist. L</ceps> is there precisely so that cannot pass unnoticed: it says how +many of them are real. + +Field names are the API's own, in Portuguese. See L<Cepis::Address> for why. + +=head1 CONSTRUCTOR + +=head2 from_json + + my $faixa = Cepis::Faixa->from_json( $hashref ); + +Builds from the C<faixa> block of a 404 body. Returns C<undef> when the block is +missing, empty, not a hash, or malformed. + +Parsing here is deliberately forgiving: a bad C<faixa> must never turn a clean +not-found into a parse failure. The caller still gets its +L<Cepis::Error::CEPNotFound>, just without the extra context. + +=head1 ACCESSORS + +=head2 localidade + +Municipality name. Always present and non-empty — a block without it does not +build. + +=head2 uf + +Two-letter state code. + +=head2 estado + +Full state name. + +=head2 regiao + +Region of the country, such as C<"Sudeste">. + +=head2 ibge + +IBGE municipality code. The stable way to identify the municipality; the name +alone is not unique across states. + +=head2 cep_ini + +The smallest CEP that exists in the municipality, unmasked. Read the warning +above before treating it as a lower bound. + +=head2 cep_fim + +The largest CEP that exists in the municipality, unmasked. + +=head2 ceps + +How many CEPs the municipality actually has, as an integer. Compare it with the +span between L</cep_ini> and L</cep_fim> to see how sparse the range is. + +=head2 label + +C<"Areal/RJ">: the municipality and its state, ready to put in a message. Falls +back to the municipality alone when the API sent no C<uf>. + +=head2 to_hash + +The eight fields as a plain hashref. + +=head1 SEE ALSO + +L<Cepis>, L<Cepis::Error>, L<Cepis::Client> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut diff --git a/lib/Cepis/Geo.pm b/lib/Cepis/Geo.pm index bee2515..c2c8fa3 100644 --- a/lib/Cepis/Geo.pm +++ b/lib/Cepis/Geo.pm @@ -5,7 +5,7 @@ use warnings; use Scalar::Util (); -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub from_json { my ( $class, $data ) = @_; diff --git a/lib/Cepis/Health.pm b/lib/Cepis/Health.pm index 6782fd0..858b543 100644 --- a/lib/Cepis/Health.pm +++ b/lib/Cepis/Health.pm @@ -5,7 +5,7 @@ use warnings; use Scalar::Util (); -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub from_json { my ( $class, $data ) = @_; |