summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/Cepis.pm17
-rw-r--r--lib/Cepis/Address.pm2
-rw-r--r--lib/Cepis/CSV.pm2
-rw-r--r--lib/Cepis/Client.pm44
-rw-r--r--lib/Cepis/Error.pm37
-rw-r--r--lib/Cepis/Faixa.pm196
-rw-r--r--lib/Cepis/Geo.pm2
-rw-r--r--lib/Cepis/Health.pm2
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 ) = @_;