in reply to Back to programming? A gossamer hope to automate SSL cert renewals
I have strong opinions on writing web service wrappers, which you might find useful. This opinion is not necessarily "what is the ultimate way" but more "what is the most pragmatic way that gets the job done and is debuggable and reusable", because I run into this task over and over and over again. (everyone has a web API these days) I've applied this pattern probably 20-30 times, and it gets a little more refined each time.
package DigiCert::Automation; =head1 DESCRIPTION Blah blah about Digicert. You need an API key to make calls. API keys blah blah blah Set L</api_key> to the secret value. The API is documented at https://dev.digicert.com/en/certcentral-apis. +html =cut use v5.36; use Moo; use Carp; use JSON::MaybeXS; use LWP::UserAgent; use Log::Any '$log', default_adapter => 'Stderr'; use URI; has base_uri => is => 'rw', default => 'https://caas.digicert.com/ +automationws/v1'; has api_key => is => 'rw', required => 1; has http_timeout => is => 'rw', default => 60, trigger => sub ($self, @) { $self->_user_agent->timeout($self->http_timeout) if $self->_has_user_agent; }; has _user_agent => ( is => 'lazy', predicate => 1 ); sub _build__user_agent($self, @) { my $ua= LWP::UserAgent->new(cookie_jar => {}); $ua->timeout($self->http_timeout) if defined $self->http_timeout; $ua->default_header(Accept => 'application/json'); $ua->default_header('X-DC-DEVKEY' => $self->api_key); $ua->add_handler('request_send', sub ($req, $ua, $h, @) { if ($log->is_trace) { my $req_text= $req->as_string; $req_text =~ s/^/ /mg; $log->trace("Digicert HTTP Request:\n$req_text"); } return; }); $ua->add_handler('response_done', sub ($res, $ua, $h, @) { if ($log->is_trace) { my $res_text= $res->as_string; $res_text =~ s/^/ /mg; $log->trace("Digicert HTTP Response:\n$res_text"); } return; }); push @{ $ua->requests_redirectable }, 'POST'; return $ua; }
Adapt that boilerplate to set whatever default headers you need, which you discovered during the 'curl' experiments above.
Note the logging integration - use whatever logger you like, but this example uses the log levels of Log::Any to control whether it dumps verbose request/response packets on every API call. I combine this with Log::Any::Adapter::Daemontools (which I wrote) so that I can use environment variables or SIGUSR1 to bump up the log level and see the API requests.
This depends on the service, but often there will be a very common 'GET' and 'POST' pattern where you compose parameters and JSON content, then decode the JSON response. There are usually "expected errors" (like the API replying that your certificate doesn't exist or can't be renewed) and "unexpected errors" like the server returning 500 due to back-end problems or calling an API method that no longer exists. I usually have these utility methods die on the unexpected errors, and return the expected errors up one more level. But, this depends on the service. I've seen some services that return a 404 for queries of a keyword that can't be found, even when the URL is perfectly valid. So this is where you account for all those quirks and provide an API that makes sense for what you're doing.
Then I add wrappers around the common parameter patterns:# Shorthand for attaching the auth token and running an API request. sub _user_agent_request($self, $req) { $log->debug("Digicert API: ".$req->method.' '.$req->uri) if $log->is_debug; my $startT= time; my $res= $self->_user_agent->request($req); my $endT= time; $log->infof("%s response: %s (took %.3f sec)", (!$log->is_debug? "Digicert API: ".$req->method.' '.$req->uri : +''), $res->status_line, $endT - $startT) if $log->is_debug or $endT-$startT > 3 or !$res->is_success; return $res; } # Shorthand that takes an HTTP response and either unpacks the data or + dies. sub _return_data_or_die_informatively($self, $res, $req) { my $data; $data= JSON->new->allow_nonref->decode($res->decoded_content) if $res->content_type =~ m,^application/.*?json\z,; if ($res->is_success && defined $data && defined $data->{data}) { return $data->{data}; } else { croak "DigiCert ".$res->status_line." -- ".$res->decoded_content; } }
In some APIs, POST requests won't have query parameters, only the json body, or maybe no json body and only the query parameters, or maybe its a mix of HTTP verbs like PATCH/DELETE/PUT. Whatever the API is doing, adapt these helpers for that.# Shorthand private method for GET request with URL parameters sub _api_get($self, $uri_tail, $query_params) { my $uri= URI->new($self->base_uri . '/' . $uri_tail); $uri->query_form(%$query_params) if $query_params; my $req= HTTP::Request->new(GET => $uri, []); my $res= $self->_user_agent_request($req); return $self->_return_data_or_die_informatively($res, $req); } # Shorthand private method for POST request with JSON packet of parame +ters sub _api_post($self, $uri_tail, $query_params, $body_data) { my $uri= URI->new($self->base_uri . '/' . $uri_tail); $uri->query_form(%$query_params) if $query_params; my $req= HTTP::Request->new(POST => $uri, [Content_Type => 'application/json'], encode_json($body_data) ); my $res= $self->_user_agent_request($req); return $self->_return_data_or_die_informatively($res, $req); }
Now that you have the boilerplate set up, start experimenting with API calls so you can get a good feel for how things are working:
perl -Ilib -MDDP -MDigicert::CertCentral -E '\ my $d= Digicert::CertCentral->new(api_key => $ENV{DIGICERT_API_KEY}) +; \ my $data= $d->_api_post("automation/viewAutomationDetails", { accoun +tId => ... }); \ p $data;'
Once you have a good feel for how things work, start making methods for the API calls. Future-you (and coworkers) will thank you if you copy the documentation from the API:
=head2 api_viewAutomationDetails Input structure: { "accountId": 5153184, "automationId": 298577, "divisionId": 677793 } Output structure: "autoStatusResponse": { "automationStatus": "INSTALL_VALIDATION_FAILED", "requestedOn": "1599716536705", "installSettings": { "installationType": "AUTO_INSTALL_AFTER_APPROVAL", "isAlwaysOn": false }, "userDetailsResponse": { "firstName": "CertCentral", "lastName": "Admin", "container": { "id": 677793, "name": "Cert Testing Inc." } } }, "autoOrderProgressResponse": { "csrGenResponse": { "csrProgress": "CSR_GENERATION_SUCCEEDED" }, "certInstallResponse": { "certInstallProgress": "INSTALL_VALIDATION_FAILED", "causeOfFailure": "Post install validation is unsuccessful, +could be due to installation failure / scan failure", "solution": "Verify certificate manually on website and canc +el this automation request if installed, otherwise retry. Contact cus +tomer support for additional help." }, "isRetryApplicable": true }, "certDetailsResponse": { "commonName": "cert-testing.com", "signatureHash": "", "orgDetails": { "name": "Cert Testing Inc.", "address": "2801 N Thanksgiving Way", "address2": "Suite 500", "city": "Lehi", "state": "Utah", "telephone": "801-701-9600" }, "sans": "", "automationProfileName": "22jul2020 01", "automationProfileId": 741.0, "automationProfileStatus": "ACTIVE", "productType": "SSL_SECURESITE_FLEX", "validityPeriod": "1Y" }, "isRequestApproval": false https://dev.digicert.com/en/certcentral-apis/automation-api/view-autom +ation-details.html =cut sub api_viewAutomationDetails($self, %params) { $params{accountId} //= $self->account_id; $params{divisionId} //= $self->division_id; length $params{automationId} or croak "automationId required"; $self->_api_post("automation/viewAutomationDetails", \%params); }
Another option is to write objects that wrap the JSON output and provide convenient access to certain parameters, and ignore the rest:
package Digicert::CertCentral; =head2 automation $atn= $api->automation($id); Return a new or cached Automation object for the given ID. =cut sub automation($self, $id) { return $self->{_automation_cache}{$id}->refresh(10) if $self->{_automation_cache}{$id}; my $atn= Digicert::CertCentral::Automation->new(api => $self, id => +$id); Scalar::Util::weaken($self->{_automation_cache}{$id}= $atn); $atn; } package Digicert::CertCentral::Automation; use v5.36; use Moo; has api => ( is => 'ro', required => 1 ); has id => ( is => 'ro', required => 1 ); has data => ( is => 'rw' ); has data_ts => ( is => 'rw' ); sub BUILD ($self, @) { # die if automation doesn't exist, before finishing constructor $self->refresh; } sub refresh($self, $max_age=0) { if (!$self->data_ts || !$max_age || time - $self->data_ts > $max_age +) { $self->data($self->api->api_viewAutomationDetails(automationId => +$self->id)); $self->data_ts(time); } $self; } sub status($self) { $self->data->{autoStatusResponse}{automationStatus}; }
of course there are a long list of decision to make about how an object can most usefully help the code that is using it, especially caching problems, which is why you want to make sure you solved the actual problem before you get down into all this mental debate about the perl API :-)
Hope that helps.
|
---|