Solving the Sparkpost Challenge

The Perl Weekly Challenge for week 9 includes an optional third challenge – essentially, use the Sparkpost service’s API to send an email.  Sparkpost is a service that allows sending emails via an HTTP interface, just by posting a JSON form response.

I’ve noticed people have not known how to solve these API-usage challenges, so I will share my method of solving them, using Perl 6.

The first thing I had to do was sign up for a Sparkpost account at their website.  I went ahead and did that, signing up for their free tier, since that provides more than enough service to solve the challenge. During the sign-up, it prompted me to configure a domain to use to send email, something I didn’t want to do just for a one-off challenge.  Instead, I skipped setting that up, deciding that the “sandbox” domain they provide would be sufficient for my testing.

During the process of signing up, you eventually see something like this:

Screenshot 2019-05-23 at 14.25.20 (1)

I of course hid the API key (think of it as a password) in the screenshot, but this actually gives all the information we need to know how to use this API to send mail.

We can see a few things we need to do to send an email:

  • We need to do an HTTPS POST
  • We need to add a custom header named “Authorization” with the API key as its value
  • We need to set the content type to JSON, so that’s what we’ll be sending with the POST – a JSON message.
  • Then the JSON message has a few sections:
    • options – this just contains “sandbox” with a true value. Ignore this for now, but I’ll return to it.
    • content – this contains a “from” (who sent the mail – in this case the “sandbox” user, but if you set up a proper domain, it would be a user in your configured domain), a “subject” (the subject of the email), and “text” which is the text of the actual email.
    • recipients – this is an array of hashs, each hash containing a key of “address” and a value of the email address we want to send the message to.  This API is designed to allow sending to lots of users.

That lets us know what information we need to be able to send an email – we need to know the API key, who is sending the email (“from”), the subject of the email, the content of the email, and the recipients.  Because I didn’t configure a domain, I need to use the sandbox account so I want my Perl 6 script to also get input about whether it is sending from a sandbox account or not (if it is, we need to include the options section in the JSON message with a “sandbox” item in it).

I decided I’d store the API key in a config file in my home directory (I’ll explain how I did this in a minute), and everything else would be configured via command line.  Fortunately, Perl 6 makes simple command line interfaces VERY simple!

#!/usr/bin/env perl6
use v6;

use Cro::HTTP::Client;

sub MAIN(
    Str:D :$from,
    Str:D :$to,
    Str:D :$subject,
    Str:D :$body-text,
    Bool  :$sandbox,
    Str:D :$config-file? = $*HOME.add(".sparkpost").Str,
) {
    my $api-key = get-api-key($config-file);

    send-email(:$api-key, :$from, :to($to.split(';')), :$subject, :$body-text, :$sandbox);
}

The first couple lines are just basic Perl 6 boiler plate – this script uses Perl 6 to run, and we locate perl6 using /usr/bin/env. The second line just says we’re using Perl 6, any version.

We then include the Cro::HTTP::Client module – I’ll explain why later.

Then we have the MAIN method – to take command line parameters, like –from=foo@example.com, we use the :$from notation – the value of $from will be populated with the “from” named parameter on the command line.

We provide types for each of the variables – Str and Bool are obvious, but the 😀 may not be. All the 😀 means is that we want the value to be defined, I.E. mandatory. So if someone doesn’t provide a “from” parameter on the command line, we want the program to give an error.

So, you must specify –from=…, –to=…, –subject=…, and –body-text=… on the command line. A boolean works a bit differently – if –sandbox is specified on the command line, $sandbox is set to True. Otherwise it doesn’t have a value (that’s fine, that evaluates to False).

Finally, we have the config file name. We do provide a default which is a file in the home directory ($*HOME) with a filename of “.sharkpost”. We then convert the IO::Path object that is created to a straight string, as if a user instead provided a value for –config-file=… on the command line, it’s value would be a string. But if no –config-file option is provided, we’ll look for a file named .sharpost in the home diectory.

An example usage of this would be something like:

./ch-3.p6 --from=joelle@example.com --to=joelle@example.com --subject="My Subject" \
          --body-text="This is a test email."

The body of the sub is really simple, as Perl 6 already did most of the wrok – we set the $api-key variable from the get-api-key() function (I’ll share that in a second) and then call a send-email subroutine which uses a bunch of named parameters. Simple!

There is one gotcha on the call to send-email(). Because the API for Sparkpost allows more than one email to be specified as a recipient, I allow a user calling this script to specify multiple email addresses by separating them by a semicolon. I then pass that list of email addresses, as a list, to send-email(). To do that, I use split() to turn the “$to” string into a list, with each element seperated by a semicolon: “:to($to.split(‘;’))”.

So that’s out of the way. But we now have to write two subroutines.

sub get-api-key(Str:D $config-file -->Str:D) {
    my @lines = $config-file.IO.lines().grep( *.chars > 0 );
    die "Config-file ($config-file) must consist of one line" if @lines.elems ≠ 1;

    return @lines[0];
}

This sub takes the config file name. The first line simply reads a list of lines in the file ($config-file.IO.lines()) and eliminates any lines that don’t have at least one character.

The next line validates that there is exactly one line with data in it. The only thing that should be in the file is the API key.

The final line returns the API key (the value of the first, and only, non-empty line in the file).

So that’s how the MAIN() sub gets the API key to pass to send-email. Now we’ve done the preliminary work, what does send-email() look like?

sub send-email(
    Str:D :$api-key,
    Str:D :$from,
          :@to,
    Str:D :$subject,
    Str:D :$body-text,
    Bool  :$sandbox,
) {
    my $url = "https://api.sparkpost.com/api/v1/";

    my $client = Cro::HTTP::Client.new(
        base-uri     => $url,
        content-type => 'application/json',
        headers      => [ Authorization => $api-key ],
    );

    my @recipients;
    for @to.unique -> $addr {
        @recipients.push: %{ address => $addr }
    }

    my %json =
        content => %{
            from    => $from,
            subject => $subject,
            text    => $body-text
        },
        recipients => @recipients,
    ;
    %json<options> = %{ sandbox => True } if $sandbox;

    my $resp = await $client.post('transmissions', body => %json);
    return;

    CATCH {
        when X::Cro::HTTP::Error {
           my $body = await .response.body;
            if $body<errors>[0]<description>:exists {
                die "Error from API endpoint: $body<errors>[0]<description>";
            } else {
                die "Error from API endpoint: $body<errors>[0]<message>";
            }
        }
    }
}

Ok, this is complex! When scanning this code, the first thing to do is look at the parameters passed into the sub:

sub send-email(
    Str:D :$api-key,
    Str:D :$from,
          :@to,
    Str:D :$subject,
    Str:D :$body-text,
    Bool  :$sandbox,
) {

In this case, we take six named parameters – 4 strings, one list (@to), and one Bool ($sandbox). I used named parameters because when you have more than one or two parameters, it can be easy to forget what order the parameters should be passed in – a problem you don’t have with named parameters.

So how did I solve the actual sending? Let’s look at the main body:

    my $url = "https://api.sparkpost.com/api/v1/";

    my $client = Cro::HTTP::Client.new(
        base-uri     => $url,
        content-type => 'application/json',
        headers      => [ Authorization => $api-key ],
    );

    my @recipients;
    for @to.unique -> $addr {
        @recipients.push: %{ address => $addr }
    }

    my %json =
        content => %{
            from    => $from,
            subject => $subject,
            text    => $body-text
        },
        recipients => @recipients,
    ;
    %json<options> = %{ sandbox => True } if $sandbox;

    my $resp = await $client.post('transmissions', body => %json);
    return;

I hardcoded the URL – that’s bad practice, but it works. In this case, I’m using the Cro::HTTP::Client (“zef install Cro::HTTP::Client” to install it) to do the actual HTTPS client. It allows specification of a “base-uri” to make actual calls a lot shorter (I can just specify the relative URI, relative to the base URI – so in this case, with a base URI of “https://api.sparkpost.com/api/v1/&#8221;, I just need to specify “transmissions” as the relative URI later.

I also set the content-type and the custom header (easy in Cro::HTTP::Client!). This provides the defaults for future method calls.

I then build a recipients list, which is in the format that the API wants. Recall that the example given by Sparkpost used a JSON file that contained a “recipients” sections. That section was an array of hashes, not just an array of strings like we have in @to. So I create a @recipients list, with each element being an anonymous hash with one key (address) that has an associated value of the recipient’s email address.

I then build a “JSON” hash. It’s a hash, not actual JSON, but Cro::HTTP::Client will turn it into proper JSON just fine (because the content-type is set to JSON, it knows to do this). I add the options key, containing an anonymous hash setting “sandbox” to True if $sandbox is True. Otherwise, there is no options key included in %json.

Finally, I do the POST to the transmissions API end point, passing %json as “body”. Cro::HTTP::Client knows that the content type is JSON, so it serializes %json as valid JSON.

I then return. Everything is good now!

There’s one more thing – I’m not going to explain this in detail, but I have to handle the possibility of things going wrong. I do that with a CATCH phaser, so I don’t have to pollute my main code block. So if the POST goes wrong, I’ll get an error and try to display that error message. This error handling code isn’t as good as it should be, but it served its’ purpose in debugging;

    CATCH {
        when X::Cro::HTTP::Error {
           my $body = await .response.body;
            if $body<errors>[0]<description>:exists {
                die "Error from API endpoint: $body<errors>[0]<description>";
            } else {
                die "Error from API endpoint: $body<errors>[0]<message>";
            }
        }
    }

That’s all, folks! Hopefully that helps you talk to an API like Sparkpost.

4 thoughts on “Solving the Sparkpost Challenge

    • Really good catch – apparently wordpress didn’t like <…> in code blocks, and just plain stripped them out silently. Grrrrr. I’ve figured out what WordPress wants now, corrected the code blocks, and it looks like it’s right now. I also changed the line that starts with %json<options> because it was stripping out the too.

      Like

  1. Pingback: 2019.21 That’s Why | Weekly changes in and around Perl 6

  2. Pingback: Converting Decimal to Roman Numbers in Perl 6 | Digital Barbed Wire

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s