# Cepis Perl client for the [public CEP API at cep.is](https://cep.is). Official Correios data (DNE), refreshed every two weeks. No non-core dependencies: `HTTP::Tiny`, `JSON::PP` and `Encode` ship with Perl, and the CSV reader is part of the distribution. ```perl use Cepis; my $address = Cepis::lookup('01001-000'); printf "%s %s %s\n", $address->logradouro, $address->localidade, $address->uf; # Praça da Sé São Paulo SP ``` ## Installation ```sh perl Makefile.PL make make test make install ``` Requires Perl 5.14 or newer — that is when `HTTP::Tiny` and `JSON::PP` entered the core. ### HTTPS needs two modules that Perl does not ship The library itself needs nothing from CPAN, but **reaching `https://cep.is` does**. `HTTP::Tiny` speaks TLS only when both of these are installed: | module | minimum version | | --- | --- | | `IO::Socket::SSL` | 1.968 | | `Net::SSLeay` | 1.49 | Neither is a core module. Without them every call against the default `base_url` fails, so install them alongside this distribution: ```sh cpanm IO::Socket::SSL Net::SSLeay # or, from your distribution's packages: # Debian/Ubuntu apt install libio-socket-ssl-perl libnet-ssleay-perl # Arch pacman -S perl-io-socket-ssl perl-net-ssleay # Alpine apk add perl-io-socket-ssl perl-net-ssleay # OpenBSD pkg_add p5-IO-Socket-SSL p5-Net-SSLeay ``` The client checks for them before it opens a socket and, when they are missing, throws `Cepis::Error::Network` immediately with a message naming both — rather than letting a bare `HTTP::Tiny` internal error surface: ``` cannot reach https://cep.is/01001000: 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 (...), or point base_url at an http:// instance. HTTP::Tiny reports: ... ``` To check without making a request: ```perl my ( $ok, $why ) = Cepis::Client::tls_available(); ``` Plain HTTP — a local instance, a test server — works on a stock Perl with nothing installed. ## Usage ### A single lookup `lookup` accepts the CEP masked or unmasked and returns a `Cepis::Address`: ```perl 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" ``` Fields the database does not have for that CEP come back as empty strings, never `undef`. `$a->field('name')` reads any field of the raw response, including one the API grew after this release. Perl does not distinguish a number from a string, so keep CEPs in strings: the literal `01001000` loses its leading zero before this library ever sees it. ### Coordinates About 57% of CEPs have a coordinate. When they do not, `geo` is `undef`: ```perl my $a = Cepis::lookup('01310100'); if ( my $geo = $a->geo ) { printf "%s %s\n", $geo->lat, $geo->lon; # -23.567202 -46.649659 print $geo->raio_m, "\n"; # 1084 print $geo->precisao, "\n"; # "logradouro" } ``` **Check `precisao` before computing distance.** There are two kinds of point, and the difference between them is tens of kilometres: | `precisao` | typical radius | what the point is | | --- | --- | --- | | `logradouro` | 110–140 m | the stretch of street — good for maps and distance | | `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city | `$geo->is_precise` is the shorthand for `precisao eq 'logradouro'`: ```perl my $cost = $a->geo && $a->geo->is_precise ? shipping_from( $a->geo->lat, $a->geo->lon ) : shipping_from_municipality( $a->ibge ); ``` ### Many CEPs at once Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs per request: ```perl my @ceps = qw(01001000 99999999 20040020); my $result = Cepis::lookup_many( \@ceps ); for my $i ( 0 .. $#ceps ) { print defined $result->[$i] ? "$ceps[$i]: " . $result->[$i]->localidade . "\n" : "$ceps[$i]: not found\n"; } ``` The returned arrayref has **the same length and the same order** as the input. A CEP that does not exist, or is malformed, is `undef` at its position — it does not vanish, and it does not throw. A Perl array holds `undef` at any position without shortening, so nothing is dropped and nothing shifts: `$result->[$i]` always corresponds to `$ceps[$i]`. That is worth stating because it is not true everywhere — in Lua, for instance, a nil in a table truncates it. Here `scalar @$result == scalar @ceps` always holds, and `grep { defined } @$result` is how you discard the misses when the pairing no longer matters. Lists longer than 500 are chunked automatically. > **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV > has no `geo` column. Every `Cepis::Address` from `lookup_many` has `geo` set > to `undef`, including for CEPs that do have one. Need `geo`? Use `lookup`, one > at a time. ### API health Useful for two things: checking the API answers, and knowing **which version of the database** is live: ```perl 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" ``` An API reporting itself degraded does not throw — that arrives in `$h->status`. ## Configuration For anything beyond the defaults, build a `Cepis::Client` and reuse it. It keeps no state between calls: ```perl use Cepis::Client; my $client = Cepis::Client->new( base_url => 'https://cep.is', # point at another instance timeout => 10.0, # seconds per socket operation max_attempts => 3, # counting the first; 1 disables retrying backoff_base => 0.5, # base of the exponential wait backoff_max => 8.0, # cap on each wait total_timeout => undef, # cap on the whole call, attempts included user_agent => undef, # defaults to cepis-perl/ ); my $address = $client->lookup('01001000'); ``` `timeout` bounds each socket operation, not the whole call — that cap is `total_timeout`, and without it the worst case is `max_attempts * timeout` plus the sum of the backoffs. Retries cover 429, 5xx and network failures, with exponential backoff and full jitter; 400 and 404 are never retried. The module-level functions (`Cepis::lookup`, and so on) share one default client, reachable as `Cepis::default_client()`. It applies process-wide, including to third-party libraries that also use this module. Need a different configuration? Build your own. Each client holds one `HTTP::Tiny`, which is not shared between threads. Give each thread its own client. ### Pointing at a local instance `base_url` is the only setting that usually changes between environments. The API repo brings a local instance up with `just up`: ```perl my $client = Cepis::Client->new( base_url => 'http://localhost:8080' ); ``` ## Errors Every failure is thrown as an object under `Cepis::Error`, so one test catches the lot. Below that, the split is by **the decision you have to make**, not by the HTTP status that caused it: | error | when | does retrying help? | | --- | --- | --- | | `Cepis::Error::InvalidCEP` | not 8 digits | no | | `Cepis::Error::CEPNotFound` | well-formed, not in the database | no | | `Cepis::Error::RateLimited` | 429 | yes, later | | `Cepis::Error::ServerError` | 5xx | yes, later | | `Cepis::Error::Network` | DNS, connection, timeout, TLS | yes, later | | `Cepis::Error::InvalidResponse` | unintelligible response | probably not | ```perl my $address = eval { Cepis::lookup($cep_from_form) }; if ( !$address ) { my $err = $@; if ( ref $err && $err->isa('Cepis::Error::CEPNotFound') ) { # ask the user for another CEP } elsif ( ref $err && $err->isa('Cepis::Error') ) { # transient failure: reschedule } else { die $err; } } ``` Errors stringify to their message, so `warn $err` and `"$err"` do the obvious thing. `$err->retry_after` carries what the server asked for, in seconds, and `$err->status` the HTTP status of the last attempt. ### A not-found that knows where it was A `Cepis::Error::CEPNotFound` often knows **which municipality** the CEP would have belonged to. When it does, `$err->faixa` is a `Cepis::Faixa`: ```perl my $address = eval { Cepis::lookup('25845000') }; if ( !$address && ref $@ && $@->isa('Cepis::Error::CEPNotFound') ) { if ( my $faixa = $@->faixa ) { $faixa->localidade; # "Areal" $faixa->uf; # "RJ" $faixa->ibge; # "3300225" $faixa->ceps; # 118 $faixa->cep_ini; # "25845001" $faixa->cep_fim; # "25849899" $faixa->label; # "Areal/RJ" } } ``` The message names the municipality too: ``` CEP 25845000 does not exist in the DNE database (it would fall in Areal/RJ) ``` 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 `-000` that 957 municipalities never received. Three things this is not: - **It is not a success.** The response is still a 404 and `lookup` still throws. `faixa` is context for the error, never an address. - **It is not always there.** When the CEP falls in a band no municipality owns, the API omits the block — `$err->faixa` is then `undef`, never a null-ish empty object. A malformed block is dropped the same way rather than turning a clean not-found into a parse failure. - **`cep_ini`..`cep_fim` is not a list of valid CEPs.** They are the smallest and the largest CEP that *actually exist* there, not the bounds the Correios publish — the DNE's own start for Areal is `25845000`, exactly the CEP that just failed. And the range is sparse: Areal's 118 CEPs are scattered across the ~4,900 numbers between them. That is what `ceps` is for. Do not iterate the interval. `Cepis::Error::InvalidResponse` almost always 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. A bad argument to `Cepis::Client->new` is a `croak`, not a `Cepis::Error`: that is a programming error, not a failed lookup. ## Development Everything runs through `make` and the `perl` on your `PATH`. There is nothing to install: the distribution and its suite use only core modules, and the tests bring up a real HTTP server on loopback. ```sh make # list the targets make test # run the suite with prove make lint # perl -c over lib, t and Makefile.PL make build # build Cepis-0.2.0.tar.gz make check # lint, then test, then build ``` `prove -l t/` works too. Perl ships no linter or formatter in its core, and this distribution depends on neither. `make lint` is therefore a `perl -c` compile check; if `perlcritic` happens to be installed it is run as well, and `make format` applies `perltidy` when that is installed. Neither is installed here, so `make lint` reports the syntax checks only. The `Makefile` in the repository is for development only and is not shipped. The tarball carries `Makefile.PL`, so installing from it is the usual `perl Makefile.PL && make && make test && make install`. Documentation lives in POD on the public API: `perldoc lib/Cepis.pm`, `perldoc lib/Cepis/Client.pm`. ## Licence ISC. See [LICENSE](LICENSE).