diff options
Diffstat (limited to 'lib/Cepis')
| -rw-r--r-- | lib/Cepis/Address.pm | 213 | ||||
| -rw-r--r-- | lib/Cepis/CSV.pm | 165 | ||||
| -rw-r--r-- | lib/Cepis/Client.pm | 652 | ||||
| -rw-r--r-- | lib/Cepis/Error.pm | 226 | ||||
| -rw-r--r-- | lib/Cepis/Geo.pm | 148 | ||||
| -rw-r--r-- | lib/Cepis/Health.pm | 138 |
6 files changed, 1542 insertions, 0 deletions
diff --git a/lib/Cepis/Address.pm b/lib/Cepis/Address.pm new file mode 100644 index 0000000..a938d6f --- /dev/null +++ b/lib/Cepis/Address.pm @@ -0,0 +1,213 @@ +package Cepis::Address; + +use strict; +use warnings; + +use Cepis::Geo; + +our $VERSION = '0.1.0'; + +our @FIELDS = qw( + cep logradouro complemento unidade bairro localidade + uf estado regiao ibge gia ddd siafi +); + +{ + no strict 'refs'; + for my $name (@FIELDS) { + *{$name} = sub { return $_[0]->{$name} }; + } +} + +sub from_json { + my ( $class, $data ) = @_; + + my %raw = %{ $data || {} }; + my %self; + for my $name (@FIELDS) { + $self{$name} = _string( $raw{$name} ); + } + $self{geo} = Cepis::Geo->from_json( $raw{geo} ); + $self{raw} = \%raw; + + return bless \%self, $class; +} + +sub geo { return $_[0]->{geo} } +sub raw { return $_[0]->{raw} } + +sub field { + my ( $self, $name ) = @_; + return $self->{raw}{$name}; +} + +sub formatted_cep { + my ($self) = @_; + my $cep = $self->{cep}; + return $cep unless length($cep) == 8; + return substr( $cep, 0, 5 ) . '-' . substr( $cep, 5, 3 ); +} + +sub to_hash { + my ($self) = @_; + my %out = map { $_ => $self->{$_} } @FIELDS; + $out{geo} = $self->{geo} ? $self->{geo}->to_hash : undef; + return \%out; +} + +sub _string { + my ($value) = @_; + return '' unless defined $value; + return '' if ref $value; + return "$value"; +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Address - a CEP and everything the database knows about it + +=head1 SYNOPSIS + + my $a = Cepis::lookup('01310-100'); + + $a->logradouro; # "Avenida Paulista" + $a->localidade; # "São Paulo" + $a->uf; # "SP" + $a->ibge; # "3550308" + $a->cep; # "01310100" (always unmasked) + $a->formatted_cep; # "01310-100" + +=head1 DESCRIPTION + +B<Field names are the API's own, in Portuguese> — C<logradouro>, C<bairro>, +C<localidade>. Everything else in this library is English; the fields are the +one deliberate exception. + +The reason is fidelity: what you read in the JSON at +L<https://cep.is/01001000> is exactly what you read on the object. Translating +to C<street>/C<city> would mean maintaining a field-by-field mapping and forcing +anyone reading the API docs alongside this library to translate back in their +head. The values are Portuguese either way — they come from the Correios +database. + +Fields the database does not have for that CEP come back as empty strings, never +C<undef>. That is what the API returns, and changing it here would create a +difference between reading the JSON and reading this library. + +L</geo> is the exception: it is C<undef> when there is no coordinate. There the +difference is real, because the API B<omits> the whole field in that case (about +43% of CEPs) rather than sending null. + +=head1 CONSTRUCTOR + +=head2 from_json + + my $address = Cepis::Address->from_json( $hashref ); + +Builds from the decoded JSON object the API returned. + +Unknown fields are ignored during construction but preserved in L</raw>. A new +field in the API therefore cannot break this library. + +A malformed C<geo> block yields C<< geo => undef >> rather than propagating the +error. + +=head1 ACCESSORS + +=head2 cep + +The 8 digits, unmasked. + +=head2 logradouro + +Street name. + +=head2 complemento + +Complement, such as C<"lado ímpar">. + +=head2 unidade + +Unit, when the CEP belongs to a specific building or post office box range. + +=head2 bairro + +Neighbourhood. + +=head2 localidade + +Municipality name. + +=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. + +=head2 gia + +GIA code (São Paulo state tax registry). + +=head2 ddd + +Area code for telephone dialling. + +=head2 siafi + +SIAFI municipality code. + +=head2 geo + +A L<Cepis::Geo> object, or C<undef> when the database has no coordinate for +this CEP. Addresses coming out of C<lookup_many> always have C<undef> here — see +L<Cepis::Client/lookup_many>. + +=head2 raw + +The decoded response exactly as it arrived, as a hashref, so a field the API +grows before this library is updated is not lost. + +=head2 field + + $address->field('campo_do_futuro'); + +Reads any field of the raw response by name, including one this release does not +know about. Returns C<undef> when the field is absent. + +=head2 formatted_cep + +The CEP with its mask: C<01001-000>. + +The API always returns it unmasked; formatting is presentation, and it lives +here so it does not become a repeated C<substr> in every project that uses this. + +=head2 to_hash + +Every named field plus C<geo> as a plain hashref, with C<geo> itself flattened +to a hashref or C<undef>. + +=head1 SEE ALSO + +L<Cepis>, L<Cepis::Geo> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut diff --git a/lib/Cepis/CSV.pm b/lib/Cepis/CSV.pm new file mode 100644 index 0000000..6fabc0c --- /dev/null +++ b/lib/Cepis/CSV.pm @@ -0,0 +1,165 @@ +package Cepis::CSV; + +use strict; +use warnings; + +our $VERSION = '0.1.0'; + +sub parse_rows { + my ($text) = @_; + + return [] unless defined $text && length $text; + $text =~ s/\A\x{FEFF}//; + + my @rows; + my @row; + my $field = ''; + my $quoted = 0; + my $started = 0; + my $length = length $text; + my $i = 0; + + while ( $i < $length ) { + my $char = substr( $text, $i, 1 ); + + if ($quoted) { + if ( $char eq '"' ) { + if ( $i + 1 < $length && substr( $text, $i + 1, 1 ) eq '"' ) { + $field .= '"'; + $i += 2; + next; + } + $quoted = 0; + $i++; + next; + } + $field .= $char; + $i++; + next; + } + + if ( $char eq '"' && $field eq '' ) { + $quoted = 1; + $started = 1; + $i++; + next; + } + + if ( $char eq ',' ) { + push @row, $field; + $field = ''; + $started = 1; + $i++; + next; + } + + if ( $char eq "\r" || $char eq "\n" ) { + $i++; + $i++ + if $char eq "\r" + && $i < $length + && substr( $text, $i, 1 ) eq "\n"; + if ( $started || @row ) { + push @row, $field; + push @rows, [@row]; + } + @row = (); + $field = ''; + $started = 0; + next; + } + + $field .= $char; + $started = 1; + $i++; + } + + if ( $started || @row ) { + push @row, $field; + push @rows, [@row]; + } + + return \@rows; +} + +sub parse_hashes { + my ($text) = @_; + + my $rows = parse_rows($text); + return ( [], [] ) unless @$rows; + + my $header = shift @$rows; + my @out; + for my $row (@$rows) { + my %record; + for my $index ( 0 .. $#$header ) { + my $value = $index <= $#$row ? $row->[$index] : ''; + $record{ $header->[$index] } = defined $value ? $value : ''; + } + push @out, \%record; + } + + return ( $header, \@out ); +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::CSV - a small RFC 4180 reader + +=head1 SYNOPSIS + + use Cepis::CSV; + + my ( $header, $records ) = Cepis::CSV::parse_hashes($text); + print $records->[0]{localidade}; + +=head1 DESCRIPTION + +Perl ships no CSV parser in its core, and this distribution has no non-core +dependencies, so it carries its own. It handles what RFC 4180 requires and what +the C<GET /csv> endpoint actually emits: quoted fields, embedded commas, embedded +newlines, C<""> as an escaped quote, C<CRLF> or C<LF> line endings, and a leading +byte order mark. + +The input is a character string, not bytes — decode the response body before +calling in. + +This module is internal to L<Cepis>. It is documented because it is tested +separately, not because it is meant to be a general-purpose CSV library: it has +no writer, no configurable separator and no streaming interface. + +=head1 FUNCTIONS + +=head2 parse_rows + + my $rows = Cepis::CSV::parse_rows($text); + +Returns an arrayref of arrayrefs, one per record, fields in order. Blank lines +are skipped, so a trailing newline does not produce a phantom final record. + +=head2 parse_hashes + + my ( $header, $records ) = Cepis::CSV::parse_hashes($text); + +Treats the first record as a header row and returns it as an arrayref, plus an +arrayref of hashrefs keyed by column name. A record with fewer fields than the +header gets empty strings for the ones it lacks; fields beyond the header are +dropped. + +Returns two empty arrayrefs for empty input. + +=head1 SEE ALSO + +L<Cepis::Client> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut diff --git a/lib/Cepis/Client.pm b/lib/Cepis/Client.pm new file mode 100644 index 0000000..35eb51b --- /dev/null +++ b/lib/Cepis/Client.pm @@ -0,0 +1,652 @@ +package Cepis::Client; + +use strict; +use warnings; + +use Carp (); +use Encode (); +use HTTP::Tiny; +use JSON::PP (); +use Scalar::Util (); +use Time::HiRes (); + +use Cepis::Address; +use Cepis::CSV; +use Cepis::Error; +use Cepis::Health; + +our $VERSION = '0.1.0'; + +use constant DEFAULT_URL => 'https://cep.is'; +use constant MAX_BATCH => 500; + +my %RETRIABLE_STATUS = map { $_ => 1 } ( 429, 500, 502, 503, 504 ); + +my $JSON = JSON::PP->new->utf8; + +my @MONTHS = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); +my %MONTH = map { $MONTHS[$_] => $_ + 1 } 0 .. $#MONTHS; + +sub normalize_cep { + my ($value) = @_; + + if ( !defined $value ) { + Cepis::Error::InvalidCEP->throw( + message => 'CEP must be a string, got undef' ); + } + if ( ref $value ) { + Cepis::Error::InvalidCEP->throw( + message => 'CEP must be a string, got a ' . ref($value) . ' reference' ); + } + + my $cleaned = "$value"; + $cleaned =~ s/\A\s+//; + $cleaned =~ s/\s+\z//; + $cleaned =~ s/[-. ]//g; + + if ( $cleaned !~ /\A[0-9]{8}\z/ ) { + Cepis::Error::InvalidCEP->throw( + message => "CEP must be 8 digits, got '$value'" ); + } + + return $cleaned; +} + +sub tls_available { + return ( 1, undef ) unless HTTP::Tiny->can('can_ssl'); + my ( $ok, $why ) = HTTP::Tiny->can_ssl; + return ( 1, undef ) if $ok; + return ( 0, $why ); +} + +sub new { + my ( $class, %args ) = @_; + + my $base_url = _default( $args{base_url}, DEFAULT_URL ); + my $timeout = _default( $args{timeout}, 10.0 ); + my $max_attempts = _default( $args{max_attempts}, 3 ); + my $backoff_base = _default( $args{backoff_base}, 0.5 ); + my $backoff_max = _default( $args{backoff_max}, 8.0 ); + + Carp::croak('max_attempts must be >= 1') + unless Scalar::Util::looks_like_number($max_attempts) && $max_attempts >= 1; + Carp::croak('timeout must be > 0') + unless Scalar::Util::looks_like_number($timeout) && $timeout > 0; + Carp::croak('backoff_base must be >= 0') + unless Scalar::Util::looks_like_number($backoff_base) && $backoff_base >= 0; + Carp::croak('backoff_max must be >= 0') + unless Scalar::Util::looks_like_number($backoff_max) && $backoff_max >= 0; + + $base_url = "$base_url"; + $base_url =~ s{/+\z}{}; + + my $user_agent = _default( $args{user_agent}, + "cepis-perl/$VERSION (+https://cep.is)" ); + + my $self = { + base_url => $base_url, + timeout => 0 + $timeout, + max_attempts => int( 0 + $max_attempts ), + backoff_base => 0 + $backoff_base, + backoff_max => 0 + $backoff_max, + total_timeout => $args{total_timeout}, + user_agent => "$user_agent", + }; + + $self->{http} = HTTP::Tiny->new( + agent => $self->{user_agent}, + timeout => $self->{timeout}, + default_headers => { + 'Accept' => 'application/json, text/csv;q=0.9, */*;q=0.1', + 'Accept-Encoding' => 'identity', + }, + ); + + return bless $self, $class; +} + +sub base_url { return $_[0]->{base_url} } +sub timeout { return $_[0]->{timeout} } +sub max_attempts { return $_[0]->{max_attempts} } +sub backoff_base { return $_[0]->{backoff_base} } +sub backoff_max { return $_[0]->{backoff_max} } +sub total_timeout { return $_[0]->{total_timeout} } +sub user_agent { return $_[0]->{user_agent} } + +sub lookup { + my ( $self, $cep ) = @_; + + my $number = normalize_cep($cep); + my ( $status, $body ) = $self->_request("/$number"); + + if ( $status == 404 ) { + Cepis::Error::CEPNotFound->throw( + message => "CEP $number does not exist in the DNE database" ); + } + if ( $status == 400 ) { + Cepis::Error::InvalidCEP->throw( + message => _error_message( $body, "CEP $number rejected" ) ); + } + + return Cepis::Address->from_json( _json($body) ); +} + +sub lookup_many { + my ( $self, $ceps ) = @_; + + Carp::croak('lookup_many expects an array reference of CEPs') + unless ref $ceps eq 'ARRAY'; + return [] unless @$ceps; + + my @out; + for ( my $start = 0; $start < @$ceps; $start += MAX_BATCH ) { + my $end = $start + MAX_BATCH - 1; + $end = $#$ceps if $end > $#$ceps; + push @out, @{ $self->_lookup_chunk( [ @{$ceps}[ $start .. $end ] ] ) }; + } + + return \@out; +} + +sub health { + my ($self) = @_; + my ( undef, $body ) = $self->_request( '/health', accepted_status => [ 200, 503 ] ); + return Cepis::Health->from_json( _json($body) ); +} + +sub _lookup_chunk { + my ( $self, $ceps ) = @_; + + my $joined = join ',', map { defined $_ ? "$_" : '' } @$ceps; + my ( undef, $body ) = $self->_request( '/csv?ceps=' . _escape($joined) ); + + my ( $header, $records ) = Cepis::CSV::parse_hashes( _decode($body) ); + unless ( grep { $_ eq 'cep' } @$header ) { + Cepis::Error::InvalidResponse->throw( message => + 'response is not the expected CSV — something between you and the ' + . 'API is answering instead of it: ' . _snippet($body) ); + } + + my @out; + for my $record (@$records) { + my $localidade = defined $record->{localidade} ? $record->{localidade} : ''; + $localidade =~ s/\A\s+//; + $localidade =~ s/\s+\z//; + if ( $localidade eq '' ) { + push @out, undef; + next; + } + push @out, Cepis::Address->from_json($record); + } + + push @out, undef while @out < @$ceps; + splice @out, scalar @$ceps if @out > @$ceps; + + return \@out; +} + +sub _request { + my ( $self, $path, %options ) = @_; + + my @accepted = @{ $options{accepted_status} || [200] }; + my %accepted = map { $_ => 1 } @accepted; + + my $url = $self->{base_url} . $path; + _assert_tls($url); + + my $deadline + = defined $self->{total_timeout} + ? _monotonic() + $self->{total_timeout} + : undef; + + my $attempts = $self->{max_attempts}; + my ( $last_status, $last_retry_after, $last_network ); + + for my $attempt ( 1 .. $attempts ) { + last if defined $deadline && _monotonic() >= $deadline; + + my $response = $self->{http}->get($url); + + if ( $response->{status} == 599 ) { + $last_network = $response->{content}; + $last_network = 'unknown transport failure' + unless defined $last_network && length $last_network; + $last_network =~ s/\s+\z//; + $last_status = undef; + $last_retry_after = undef; + } + else { + my $status = $response->{status}; + if ( $accepted{$status} || !$RETRIABLE_STATUS{$status} ) { + return ( $status, $response->{content} ); + } + $last_status = $status; + $last_retry_after = _retry_after_seconds( $response->{headers}{'retry-after'} ); + $last_network = undef; + } + + last if $attempt == $attempts; + + my $wait = $self->_backoff( $attempt, $last_retry_after ); + if ( defined $deadline ) { + my $remaining = $deadline - _monotonic(); + last if $remaining <= 0; + $wait = $remaining if $wait > $remaining; + } + Time::HiRes::sleep($wait) if $wait > 0; + } + + my $suffix = "(after $attempts attempt(s))"; + + if ( defined $last_status && $last_status == 429 ) { + Cepis::Error::RateLimited->throw( + message => "rate limit reached at $url $suffix", + retry_after => $last_retry_after, + ); + } + if ( defined $last_status ) { + Cepis::Error::ServerError->throw( + message => "the API answered $last_status at $url $suffix", + status => $last_status, + ); + } + Cepis::Error::Network->throw( + message => "could not reach $url $suffix: " + . ( defined $last_network ? $last_network : 'no attempt was made' ) ); +} + +sub _assert_tls { + my ($url) = @_; + + return unless $url =~ m{\Ahttps://}i; + + my ( $ok, $why ) = tls_available(); + return if $ok; + + $why = '' unless defined $why; + $why =~ s/\s+/ /g; + $why =~ s/\A\s+//; + $why =~ s/\s+\z//; + + Cepis::Error::Network->throw( message => + "cannot reach $url: HTTP::Tiny has no HTTPS support on this Perl. " + . 'It needs IO::Socket::SSL 1.968 or later and Net::SSLeay 1.49 or ' + . 'later, and neither of those ships with Perl. Install both ' + . '(cpanm IO::Socket::SSL Net::SSLeay, or your distribution\'s ' + . 'perl-IO-Socket-SSL and perl-Net-SSLeay packages), or point ' + . 'base_url at an http:// instance. HTTP::Tiny reports: ' + . ( length $why ? $why : 'no reason given' ) ); +} + +sub _backoff { + my ( $self, $attempt, $retry_after ) = @_; + + if ( defined $retry_after ) { + return $retry_after < $self->{backoff_max} + ? $retry_after + : $self->{backoff_max}; + } + + my $ceiling = $self->{backoff_base} * ( 2**( $attempt - 1 ) ); + $ceiling = $self->{backoff_max} if $ceiling > $self->{backoff_max}; + return rand() * $ceiling; +} + +sub _retry_after_seconds { + my ($value) = @_; + + $value = $value->[0] if ref $value eq 'ARRAY'; + return undef unless defined $value; + + my $text = "$value"; + $text =~ s/\A\s+//; + $text =~ s/\s+\z//; + return undef unless length $text; + + if ( $text =~ /\A[0-9]+(?:\.[0-9]+)?\z/ ) { + return 0 + $text; + } + + my $epoch = _http_date_epoch($text); + return undef unless defined $epoch; + + my $seconds = $epoch - time(); + return $seconds > 0 ? $seconds : 0; +} + +sub _http_date_epoch { + my ($text) = @_; + + my ( $day, $month, $year, $hour, $minute, $second ); + + if ( $text + =~ /\A[A-Za-z]+,\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT\z/ ) + { + ( $day, $month, $year, $hour, $minute, $second ) = ( $1, $2, $3, $4, $5, $6 ); + } + elsif ( $text + =~ /\A[A-Za-z]+,\s+(\d{2})-([A-Za-z]{3})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT\z/ ) + { + ( $day, $month, $year, $hour, $minute, $second ) = ( $1, $2, $3, $4, $5, $6 ); + $year += $year < 70 ? 2000 : 1900; + } + elsif ( $text + =~ /\A[A-Za-z]+\s+([A-Za-z]{3})\s+([\d ]\d)\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\z/ ) + { + ( $month, $day, $hour, $minute, $second, $year ) = ( $1, $2, $3, $4, $5, $6 ); + $day =~ s/\s//g; + } + else { + return undef; + } + + my $number = $MONTH{ ucfirst lc $month }; + return undef unless defined $number; + + return _days_from_civil( $year + 0, $number, $day + 0 ) * 86400 + + $hour * 3600 + + $minute * 60 + + $second; +} + +sub _days_from_civil { + my ( $year, $month, $day ) = @_; + + $year -= 1 if $month <= 2; + my $era = int( ( $year >= 0 ? $year : $year - 399 ) / 400 ); + my $yoe = $year - $era * 400; + my $doy = int( ( 153 * ( $month + ( $month > 2 ? -3 : 9 ) ) + 2 ) / 5 ) + $day - 1; + my $doe = $yoe * 365 + int( $yoe / 4 ) - int( $yoe / 100 ) + $doy; + + return $era * 146097 + $doe - 719468; +} + +sub _monotonic { + if ( defined &Time::HiRes::clock_gettime && defined &Time::HiRes::CLOCK_MONOTONIC ) { + return Time::HiRes::clock_gettime( Time::HiRes::CLOCK_MONOTONIC() ); + } + return Time::HiRes::time(); +} + +sub _json { + my ($body) = @_; + + my $data = eval { $JSON->decode( defined $body ? $body : '' ) }; + if ( !defined $data ) { + Cepis::Error::InvalidResponse->throw( message => + 'response is not valid JSON — something between you and the API ' + . 'is answering instead of it: ' . _snippet($body) ); + } + if ( ref $data ne 'HASH' ) { + Cepis::Error::InvalidResponse->throw( + message => 'expected a JSON object, got ' . ( ref($data) || 'a scalar' ) ); + } + + return $data; +} + +sub _error_message { + my ( $body, $fallback ) = @_; + + my $data = eval { $JSON->decode( defined $body ? $body : '' ) }; + return $fallback unless ref $data eq 'HASH'; + return $fallback unless defined $data->{mensagem} && !ref $data->{mensagem}; + return "$data->{mensagem}"; +} + +sub _decode { + my ($body) = @_; + return '' unless defined $body; + my $text = Encode::decode( 'UTF-8', $body, Encode::FB_DEFAULT() ); + $text =~ s/\A\x{FEFF}//; + return $text; +} + +sub _snippet { + my ($body) = @_; + my $text = defined $body ? substr( $body, 0, 120 ) : ''; + $text =~ s/([^\x20-\x7E])/sprintf('\\x%02X', ord $1)/ge; + return "'$text'"; +} + +sub _escape { + my ($value) = @_; + my $bytes = Encode::encode( 'UTF-8', "$value" ); + $bytes =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord $1)/ge; + return $bytes; +} + +sub _default { + my ( $value, $fallback ) = @_; + return defined $value ? $value : $fallback; +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Client - configurable client for the cep.is API + +=head1 SYNOPSIS + + use Cepis::Client; + + my $client = Cepis::Client->new( + base_url => 'https://cep.is', + timeout => 10.0, + max_attempts => 3, + backoff_base => 0.5, + backoff_max => 8.0, + total_timeout => 15.0, + ); + + my $address = $client->lookup('01001-000'); + my $result = $client->lookup_many( [ '01001000', '99999999' ] ); + my $health = $client->health; + +=head1 DESCRIPTION + +Build one instance and reuse it. The configuration is fixed at construction and +no method keeps state between calls, so a single client can serve a whole +process. It holds one L<HTTP::Tiny> internally, which keeps the connection alive +between calls; that object is not shared between threads, so give each thread its +own client. + +Retries cover 429, 500, 502, 503, 504 and network failures, with exponential +backoff and full jitter. 400 and 404 are never retried: both are definitive +answers about the request, so retrying returns the same thing more slowly while +burning someone's quota. + +=head2 HTTPS support + +L<HTTP::Tiny> is a core module and speaks plain HTTP out of the box, but it +speaks B<HTTPS only when L<IO::Socket::SSL> 1.968 or later and L<Net::SSLeay> +1.49 or later are installed>, and neither of those ships with Perl. This library +therefore has no non-core dependencies, but the default C<base_url> of +C<https://cep.is> needs those two modules present. + +When they are missing, a request to an C<https:> URL throws +L<Cepis::Error::Network> immediately, before any attempt is made, with a message +naming both modules. No amount of retrying fixes a missing module, so it does +not retry. + +L</tls_available> reports the same thing without making a request. + +=head1 FUNCTIONS + +=head2 normalize_cep + + my $number = Cepis::Client::normalize_cep('01001-000'); # "01001000" + +Returns the CEP's 8 digits, accepting the mask. C<"01001-000">, C<"01001000">, +C<" 01001-000 "> and C<"01001.000"> all become C<"01001000">. + +Throws L<Cepis::Error::InvalidCEP> unless exactly 8 digits remain. This runs +B<before> any request: a 7-digit CEP is the caller's bug, and spending a round +trip for the server to say so wastes both sides' time. + +Note that Perl does not distinguish a number from a string, so passing the +literal C<01001000> passes a 7-digit number — the leading zero is gone before +this function ever sees it. Keep CEPs in strings. + +Also reachable as C<Cepis::normalize_cep>. + +=head2 tls_available + + my ( $ok, $why ) = Cepis::Client::tls_available(); + +Reports whether L<HTTP::Tiny> on this Perl can do HTTPS. Returns a true value +and C<undef> when it can; false and the reason when it cannot. See +L</"HTTPS support">. + +=head1 CONSTRUCTOR + +=head2 new + + my $client = Cepis::Client->new(%options); + +All options have defaults, so C<< Cepis::Client->new >> is valid. C<croak>s on a +value that cannot work, which is a programming error rather than a +L<Cepis::Error>. + +=over 4 + +=item base_url + +Where the API lives. Defaults to C<https://cep.is>. Point it at a local instance +(the API repo brings one up on C<http://localhost:8080> with C<just up>) or at a +test server. It is the only option that usually changes between environments. +Trailing slashes are stripped, so it cannot produce doubled separators that some +proxies normalise and others 404. + +=item timeout + +Seconds to wait B<per socket operation> — L<HTTP::Tiny> applies it to the +connect and to each read, so it is not a cap on the whole call. That cap is +L</total_timeout>. Defaults to 10. + +=item max_attempts + +How many attempts in total, counting the first. C<1> disables retrying. The +default of C<3> covers the common case (one transient failure) without turning a +real outage into a long wait. + +=item backoff_base + +Base of the exponential wait, in seconds; defaults to 0.5. The actual wait is +drawn from C<[0, base * 2**(n-1)]> — I<full jitter>. The randomness is not +decoration: without it, N clients that fail together come back together and +reproduce the spike that took the service down. + +=item backoff_max + +Cap on each individual wait, in seconds; defaults to 8. It applies to a +server-sent C<Retry-After> too, or a C<Retry-After: 3600> from a misconfigured +proxy would park the process for an hour. + +=item total_timeout + +Cap on the whole call, attempts and waits included. C<undef> (the default) +leaves L</max_attempts> as the only bound. Set it in code serving a web request: +without it the worst case is C<max_attempts * timeout> plus the sum of the +backoffs. + +=item user_agent + +Overrides the C<User-Agent>. The default identifies the library and its version, +which is what lets the server diagnose an old client misbehaving. + +=back + +=head1 METHODS + +=head2 lookup + + my $address = $client->lookup('01001-000'); + +Looks up a single CEP and returns a L<Cepis::Address>. Accepts the CEP masked or +unmasked. + +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. + +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, +L<Cepis::Error::ServerError> on 5xx after every attempt, +L<Cepis::Error::Network> when the request never completed, and +L<Cepis::Error::InvalidResponse> when a success status carries an unreadable +body. + +=head2 lookup_many + + my $result = $client->lookup_many( \@ceps ); + +Looks up many CEPs in one request each 500 and returns an B<arrayref of the same +length and in the same order as the input>. A CEP that does not exist is +C<undef> at its position — it does not vanish from the list, and it does not +throw. That is the right difference from L</lookup>: in a batch of a thousand, +one bad CEP must not cost the other nine hundred and ninety-nine. + +A malformed CEP is also C<undef>, for the same reason, rather than throwing. To +know I<which> one was malformed, validate first with L</normalize_cep>. + +The result is an arrayref rather than a list precisely because it may contain +C<undef>. A Perl array holds C<undef> at any position without shortening — no +element is lost and nothing shifts — so C<< $result->[$i] >> always corresponds +to C<< $ceps[$i] >>. Reading the result with C<grep { defined }> discards the +misses; reading it with C<for my $i ( 0 .. $#$result )> keeps the pairing. + +Input longer than 500 is chunked automatically. Do not loop over L</lookup>: a +loop of 500 calls pays 500 TLS handshakes, the batch pays one. + +B<The batch carries no coordinate.> The endpoint behind it is CSV, and the CSV +has no C<geo> column. Every L<Cepis::Address> returned here has C<geo> set to +C<undef>, including for CEPs that do have one. Need C<geo>? Use L</lookup>, one +at a time. + +The API guarantees one row per requested CEP. Should that ever fail to hold — a +proxy truncating, a cut response — the result is padded with C<undef> to the +input length rather than returned short, because a shifted list silently pairs +the wrong address with the wrong CEP. + +=head2 health + + my $health = $client->health; + +Queries C<GET /health> and returns a L<Cepis::Health>: does the API answer, and +with which database. + +Does not throw for an API reporting itself degraded — that arrives in +C<< $health->status >>, and checking health is exactly when you want the +diagnosis rather than an exception. Network failures still throw. + +=head1 ACCESSORS + +C<base_url>, C<timeout>, C<max_attempts>, C<backoff_base>, C<backoff_max>, +C<total_timeout> and C<user_agent> each return the configured value. + +=head1 CONSTANTS + +=head2 DEFAULT_URL + +C<https://cep.is>. + +=head2 MAX_BATCH + +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> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut diff --git a/lib/Cepis/Error.pm b/lib/Cepis/Error.pm new file mode 100644 index 0000000..080b0a1 --- /dev/null +++ b/lib/Cepis/Error.pm @@ -0,0 +1,226 @@ +package Cepis::Error; + +use strict; +use warnings; + +our $VERSION = '0.1.0'; + +use overload + '""' => sub { $_[0]->message }, + 'bool' => sub { 1 }, + fallback => 1; + +sub new { + my ( $class, %args ) = @_; + my $self = {%args}; + $self->{message} = $class unless defined $self->{message}; + return bless $self, $class; +} + +sub throw { + my $class = shift; + die $class->new(@_); +} + +sub message { return $_[0]->{message} } + +package Cepis::Error::InvalidCEP; + +use strict; +use warnings; +use parent -norequire, 'Cepis::Error'; + +our $VERSION = '0.1.0'; + +package Cepis::Error::CEPNotFound; + +use strict; +use warnings; +use parent -norequire, 'Cepis::Error'; + +our $VERSION = '0.1.0'; + +package Cepis::Error::Network; + +use strict; +use warnings; +use parent -norequire, 'Cepis::Error'; + +our $VERSION = '0.1.0'; + +package Cepis::Error::ServerError; + +use strict; +use warnings; +use parent -norequire, 'Cepis::Error'; + +our $VERSION = '0.1.0'; + +sub status { return $_[0]->{status} } + +package Cepis::Error::RateLimited; + +use strict; +use warnings; +use parent -norequire, 'Cepis::Error'; + +our $VERSION = '0.1.0'; + +sub retry_after { return $_[0]->{retry_after} } + +package Cepis::Error::InvalidResponse; + +use strict; +use warnings; +use parent -norequire, 'Cepis::Error'; + +our $VERSION = '0.1.0'; + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Error - exception hierarchy for L<Cepis> + +=head1 SYNOPSIS + + use Cepis; + + my $address = eval { Cepis::lookup($cep_from_form) }; + if ( !$address ) { + my $err = $@; + if ( ref $err && $err->isa('Cepis::Error::CEPNotFound') ) { + # the CEP does not exist: ask the user for another + } + elsif ( ref $err && $err->isa('Cepis::Error') ) { + # network, server, rate limit: retry later + } + else { + die $err; + } + } + +=head1 DESCRIPTION + +Every failure raised by this library is an object thrown with C<die>, and every +one of them inherits from C<Cepis::Error>. One C<< ref $@ && $@->isa('Cepis::Error') >> +therefore catches the lot. + +Below the base class the split is by B<the decision the caller has to make>, +not by the HTTP status that caused it: + +=over 4 + +=item * + +L</Cepis::Error::InvalidCEP> and L</Cepis::Error::CEPNotFound> are problems with +the I<request>. Retrying is pointless — the same CEP will fail again. + +=item * + +L</Cepis::Error::Network>, L</Cepis::Error::ServerError> and +L</Cepis::Error::RateLimited> are problems with I<transport>. Retrying may help, +and the client already retried on its own before throwing any of them. + +=back + +That split is why C<CEPNotFound> is not "the HTTP 404 exception": C<InvalidCEP> +is thrown without any request being made at all, when the CEP is malformed +before it ever leaves the machine. + +Every error stringifies to its message, so C<warn $@> and C<"$@"> do the +obvious thing. + +=head1 CLASSES + +=head2 Cepis::Error + +Base of every error. The only accessor it guarantees is C<message>. + +=over 4 + +=item C<< Cepis::Error->new( message => $text, %fields ) >> + +Builds the object. Extra fields are stored as-is and become available to +subclasses. + +=item C<< Cepis::Error->throw( message => $text, %fields ) >> + +Builds the object and C<die>s with it. + +=item C<< $err->message >> + +The human-readable message. Also what the object stringifies to. + +=back + +=head2 Cepis::Error::InvalidCEP + +The CEP is not 8 digits. + +Usually thrown B<without> a request ever being made — the client checks the +format before going to the network. It is also thrown when the API answers 400 +to a CEP that passed local validation, in which case it carries the API's own +message. + +=head2 Cepis::Error::CEPNotFound + +The CEP is well-formed but does not exist in the DNE database. + +=head2 Cepis::Error::Network + +The request never completed: DNS, refused connection, timeout, TLS. + +Thrown only after every configured attempt has failed — with one deliberate +exception. When C<base_url> is C<https:> and Perl has no TLS support installed, +this is thrown immediately, before any attempt, with a message naming the two +modules to install. Retrying cannot fix a missing module. See +L<Cepis::Client/"HTTPS support">. + +=head2 Cepis::Error::ServerError + +The API answered 5xx on every attempt. + +=over 4 + +=item C<< $err->status >> + +The HTTP status seen on the last attempt. + +=back + +=head2 Cepis::Error::RateLimited + +The API, or the edge in front of it, answered 429 on every attempt. + +=over 4 + +=item C<< $err->retry_after >> + +What the server asked for, in seconds, or C<undef> if it did not say. The +client already honoured this across the attempts it made; it is exposed for +callers who want to reschedule the work. + +=back + +=head2 Cepis::Error::InvalidResponse + +A success status came back with a body that cannot be parsed. + +In practice this means something between you and the API is answering instead +of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful +as a diagnosis: the problem is not in your code. + +=head1 SEE ALSO + +L<Cepis>, 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 new file mode 100644 index 0000000..bee2515 --- /dev/null +++ b/lib/Cepis/Geo.pm @@ -0,0 +1,148 @@ +package Cepis::Geo; + +use strict; +use warnings; + +use Scalar::Util (); + +our $VERSION = '0.1.0'; + +sub from_json { + my ( $class, $data ) = @_; + + return undef unless ref $data eq 'HASH'; + + for my $key (qw(lat lon raio_m precisao)) { + return undef unless defined $data->{$key}; + } + for my $key (qw(lat lon raio_m)) { + return undef unless Scalar::Util::looks_like_number( $data->{$key} ); + } + return undef if ref $data->{precisao}; + + return bless { + lat => 0 + $data->{lat}, + lon => 0 + $data->{lon}, + raio_m => int( 0 + $data->{raio_m} ), + precisao => "$data->{precisao}", + }, $class; +} + +sub lat { return $_[0]->{lat} } +sub lon { return $_[0]->{lon} } +sub raio_m { return $_[0]->{raio_m} } +sub precisao { return $_[0]->{precisao} } + +sub is_precise { return $_[0]->{precisao} eq 'logradouro' ? 1 : 0 } + +sub to_hash { + my ($self) = @_; + return { + lat => $self->{lat}, + lon => $self->{lon}, + raio_m => $self->{raio_m}, + precisao => $self->{precisao}, + }; +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Geo - the coordinate of a CEP + +=head1 SYNOPSIS + + my $address = Cepis::lookup('01310100'); + + if ( my $geo = $address->geo ) { + printf "%s %s\n", $geo->lat, $geo->lon; # -23.567202 -46.649659 + print $geo->raio_m, "\n"; # 1084 + print $geo->precisao, "\n"; # logradouro + } + +=head1 DESCRIPTION + +The CEP's coordinate, when the database has one — about 57% of them. When there +is none, C<< $address->geo >> is C<undef>; the API omits the whole block rather +than sending null. + +The point is the B<median> of the coordinates of that CEP's addresses, not the +mean: one badly geocoded address 40 km away shifts a mean and does not move a +median. + +B<Always check C<precisao> before using C<lat>/C<lon> for distance.> There are +two kinds of point and the difference between them is tens of kilometres: + +=over 4 + +=item C<"logradouro"> + +The point is a stretch of street. C<raio_m> lands around 100–150 m. Good for +maps and for distance. + +=item C<"localidade"> + +The CEP I<is> the whole municipality (a city-wide general CEP), and the point is +its centroid. C<raio_m> runs from 7 km to 40 km. Computing shipping from this is +wrong by tens of kilometres. + +=back + +C<raio_m> is the radius containing 90% of that CEP's addresses. It exists +precisely so that error cannot pass unnoticed. + +Field names are the API's own, in Portuguese. See L<Cepis::Address> for why. + +=head1 CONSTRUCTOR + +=head2 from_json + + my $geo = Cepis::Geo->from_json( $hashref ); + +Builds from the C<geo> block the API returned. Returns C<undef> when the block +is missing, is not a hash, or is malformed — a bad C<geo> is not worth losing +the whole address over, since the rest of the response is still useful. + +=head1 ACCESSORS + +=head2 lat + +Latitude, as a number. + +=head2 lon + +Longitude, as a number. + +=head2 raio_m + +Radius in metres containing 90% of that CEP's addresses, as an integer. + +=head2 precisao + +Either C<"logradouro"> or C<"localidade">. Read the warning above before using +the coordinate for anything metric. + +=head2 is_precise + +True when the coordinate points at a street rather than a municipality. +Shorthand for C<< $geo->precisao eq 'logradouro' >>, which is the check to make +before using the point to measure distance. + +=head2 to_hash + +The four fields as a plain hashref. + +=head1 SEE ALSO + +L<Cepis>, L<Cepis::Address> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut diff --git a/lib/Cepis/Health.pm b/lib/Cepis/Health.pm new file mode 100644 index 0000000..6782fd0 --- /dev/null +++ b/lib/Cepis/Health.pm @@ -0,0 +1,138 @@ +package Cepis::Health; + +use strict; +use warnings; + +use Scalar::Util (); + +our $VERSION = '0.1.0'; + +sub from_json { + my ( $class, $data ) = @_; + + my %raw = %{ $data || {} }; + + return bless { + status => _string( $raw{status} ), + dne => _string( $raw{dne} ), + ceps => _integer( $raw{ceps} ), + geo => _integer( $raw{geo} ), + base_gerada_em => _string( $raw{base_gerada_em} ), + raw => \%raw, + }, $class; +} + +sub status { return $_[0]->{status} } +sub dne { return $_[0]->{dne} } +sub ceps { return $_[0]->{ceps} } +sub geo { return $_[0]->{geo} } +sub base_gerada_em { return $_[0]->{base_gerada_em} } +sub raw { return $_[0]->{raw} } + +sub ok { return $_[0]->{status} eq 'ok' ? 1 : 0 } + +sub to_hash { + my ($self) = @_; + return { + status => $self->{status}, + dne => $self->{dne}, + ceps => $self->{ceps}, + geo => $self->{geo}, + base_gerada_em => $self->{base_gerada_em}, + }; +} + +sub _string { + my ($value) = @_; + return '' unless defined $value; + return '' if ref $value; + return "$value"; +} + +sub _integer { + my ($value) = @_; + return 0 unless defined $value; + return 0 unless Scalar::Util::looks_like_number($value); + return int( 0 + $value ); +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Health - the answer of C<GET /health> + +=head1 SYNOPSIS + + my $h = Cepis::health(); + + $h->ok; # 1 + $h->dne; # "26071" identifier of the fortnightly database + $h->ceps; # 1596168 + $h->base_gerada_em; # "2026-07-24T03:48:28Z" + +=head1 DESCRIPTION + +Good for two things: checking that the API answers, and knowing B<which version +of the database> is live. The DNE database changes every two weeks; L</dne> +identifies it. + +An API reporting itself degraded does not raise — that arrives in L</status>. + +=head1 CONSTRUCTOR + +=head2 from_json + + my $health = Cepis::Health->from_json( $hashref ); + +Builds from the decoded JSON object the API returned. + +=head1 ACCESSORS + +=head2 status + +C<"ok"> when the API considers itself healthy, something else otherwise. + +=head2 ok + +True when L</status> is exactly C<"ok">. + +=head2 dne + +Identifier of the DNE database currently loaded. It changes every fortnight. + +=head2 ceps + +How many CEPs the database holds. + +=head2 geo + +How many of those CEPs carry a coordinate. Note that unlike +L<Cepis::Address/geo> this is a count, not an object — that is what the API +sends. + +=head2 base_gerada_em + +When the database was generated, as an ISO 8601 timestamp string. + +=head2 raw + +The decoded response exactly as it arrived, as a hashref. + +=head2 to_hash + +The five named fields as a plain hashref. + +=head1 SEE ALSO + +L<Cepis>, L<Cepis::Client> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut |