My wife runs an after-school pottery program in various elementary schools around town. Parents sign their kids up for the classes, and then my wife’s staff go out to the various schools and teach basic ceramics to the kids. All the existing student registration systems for this are both wildly expensive, and they suck. She asked me if I could solve at least one of those problems for her.
I thought maybe I’d write about the process.
So I’m planning on building a product ready application that will (eventually) have other developers involved, and possibly be open sourced. So let’s lay out some ground rules for what that actually means project wise:
1) Modern Perl 5 Best Practices
Perl has moved a long way since the dot-com era, and we should take advantage of that. This means we’ll be using perl-5.30, and make it as easy as possible to test upgrading the version of perl to keep us within the Perl Maintenance Window
2) Structured to encourage others to contribute
I know of at least one other person interested in this project for a non-profit. I also know I work a full time job other than this project and will eventually need help. So I want to ensure that the application is dead simple for someone else to get up and get running at least in a dev environment quickly so they can contribute as quickly as possible to the code.
This also means that there should be a robust test suite for everything.
3) Designed to minimize the differences between dev, staging, and production
I’ve been doing a lot of junior DevOps for the last couple years. I hate differences between my development environment and production and having to account for those. So I want production and dev to be as close as possible.
First thing we do is we go to GitHub and create a new repository. I pick the
Perl 5 .gitignore
file and since at the moment this isn’t an open project no
license.
Then clone the new repository and start setting up the scaffolding. Because I use Carton, I start by dropping a CPAN file of just the following:
on 'develop' => sub {
requires 'Perl::Tidy';
};
This allows us to set a house style for the code layout. We may eventually upgrade to something like Code::TidyAll but right now we don’t even really have Perl yet.
Second thing we do is make sure that our dev, testing, and staging environments are as similar as possible. I’m reminded recently of this meme:
To make sure all my environments are a similar as possible, I built a
Dockerfile based off the perl docker images that basically installs some
basics (like carton, copies the working directory over, runs carton install
--deployment
, and starts the app up. Credit where it’s due, this is based on
the docker files that sungo did for work.
FROM perl:5.30.
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
carton \
libssl-dev \
libzip-dev \
libpq-dev \
&& apt-get clean
RUN mkdir -p /usr/local/my-app
WORKDIR /usr/local/my-app
COPY . /usr/local/my-app
RUN rm -rf /usr/local/my-app/local
ARG VCS_REF="master"
ARG VERSION="v0.0.0-dirty"
ENV HARNESS_OPTIONS j6:c
RUN carton install --deployment
ENV LANG C.UTF-8
ENV EV_EXTRA_DEFS -DEV_NO_ATFORK
ENTRYPOINT ["carton", "exec"]
CMD ["morbo", "app.pl"]
I’ve also got a docker-compose.yml
file that sets up a simple dev environment
using the image build from this Docker file.
---
version: '3.3'
services:
registry:
image: sacregistry:latest
container_name: myapp-app
restart: always
environment:
- SHOPIFY_API_KEY
- SHOPIFY_API_SECRET
volumes:
- ./app.pl:/usr/local/sacregistry/app.pl
ports:
- 3000:3000
Shopify has some lovely tutorials using various modern technologies like Node + React, Node + Express, or even ancient legacy choices like Ruby + Sinatra. I like Perl. The steps for creating a Partner account and an app are the same. So just go follow them, I’ll wait. You can go ahead and use their setup with ngrok too, it’ll play nice with our set up.
Now that you’ve done that let’s replace their Step 3 with Perl. Because we want
others to participate in development we’re going to be starting with
Mojolicious::Lite. Add the following to the cpanfile
:
requires 'IO::Socket::SSL' => 2.009;
requires 'Mojolicious';
requires 'Mojolicious::Plugin::Util::RandomString';
The extra requirement on IO::Socket::SSL
is because without it the docker
image I’m using was defaulting to something lower and Mojolicious was throwing
a fit. That may be different by the time you’re reading this.
Next we build a simple app, create an app.pl
and add the following:
use 5.30.1;
use Mojolicious::Lite -signatures;
get "/" => sub ($c) {
$c->render(text => "Hello, World");
};
app->run;
Then we can run docker build -t myapp:latest .
to rebuild the app and
docker-compose up
and it’ll start up the app. This is great but we can’t
really do much with this. So now we start laying in the shopify specific routes
(Part 4 in the Shopify tutorial).
We should export the API key and API secret you got from Shopify into the environment. In a bash shell I do the following:
export SHOPIFY_API_KEY="NOT A REAL KEY"
export SHOPIFY_API_SECRET="NOT A REAL SECRET EITHER"
These will get picked up by docker (specifically see the environment
key in
the docker-config.yml
) and passed through to the application.
And update our app.pl
with a shopify route.
use 5.30.1;
use Mojolicious::Lite -signatures;
my $API_KEY = $ENV{SHOPIFY_API_KEY};
my $API_SECRET = $ENV{SHOPIFY_API_SECRET};
plugin 'Util::RandomString';
get "/" => sub ($c) {
$c->render(text => "Hello, World");
};
get "/shopify" => sub ($c) {
my $shop = $c->param('shop') =~ s/\.myshopify\.com//r;
my $scopes = "write_orders,read_customers"; # TODO Replace with your own scopes
my $redirect_uri = $c->url_for("/shopify/connect")->userinfo(undef)->to_abs;
my $state = $c->random_string();
my $url = "https://$shop.myshopify.com/admin/oauth/authorize"
. "?client_id=$API_KEY"
. "&scope=$scopes"
. "&redirect_uri=$redirect_uri"
. "&state=$state";
$c->session(state => $state);
$c->redirect_to($url);
};
app->run;
This will take an Invite link from Shopify (the tutorial explains how to Generate those) and redirects to the app authorization prompt. The user then verifies the permissions and can click an “Install” button which will send them back to the callback route you white-listed when you set up the app.
Next we’ll need to handle that connect callback:
get "/shopify/connect" => sub ($c) {
my $code = $c->param('code');
my $shop = $c->param('shop');
if ($c->param('state') != $c->session('state')) {
die "Request origin cannot be verified"
}
if ( $shop !~ m|https?\://[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com/?| ) {
$c->ua->post_p(
"https://$shop/admin/oauth/access_token" => json => {
client_id => $API_KEY,
client_secret => $API_SECRET,
code => $code,
}
)->then(
sub ($tx) {
my $access_token = $tx->result->json->{access_token};
$c->render( text => "Got an access token, let's do something with it" );
# TODO use the access token to access the store
}
);
}
else {
die 'Bad Shop in Args';
}
};
But as Shopify’s tutorials point out we still need to validate that these
requests come from Shopify. So let’s add the following helper to our app.pl
:
use Digest::SHA qw(hmac_sha256_hex);
helper is_shopify_request => sub ($c) {
my $params = $c->req->query_params->clone;
my $hmac = $params->param('hmac');
$params = $params->remove('hmac');
return 0 unless $hmac;
return $hmac eq hmac_sha256_hex( $params->to_string, $API_SECRET );
};
now we can update the /shopify/callback
handler with the following:
get "/connect" => sub ($c) {
unless ( $c->is_shopify_request ) {
die "HMAC validation failed";
}
And it will validate the HMAC to ensure that the request came from Shopify and nobody else.
Finally we should do something with the token we get back, not just be excited we have it. So let’s replace:
$c->render( text => "Got an access token, let's do something with it" );
# TODO use the access token to access the store
with something like:
my $access_token = $tx->result->json->{access_token};
$c->ua->get_p(
"https://$shop/admin/api/2020-01/shop.json" => {
'X-Shopify-Access-Token' => $access_token
}
)->then(
sub ($tx) {
$c->render( text => $tx->result->body );
}
)->catch( sub { die "Request failed: @_" } );
This fetches the shop
endpoint json and displays it as plain text. The full
controller should look like:
get "/connect" => sub ($c) {
unless ( $c->is_shopify_request ) {
die "HMAC validation failed";
}
my $code = $c->param('code');
my $shop = $c->param('shop');
if ( $c->param('state') ne $c->session->{state} ) {
die "Request origin cannot be verified";
}
if ( $shop !~ m|https?\://[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com/?| ) {
$c->ua->post_p(
"https://$shop/admin/oauth/access_token" => json => {
client_id => $API_KEY,
client_secret => $API_SECRET,
code => $code,
}
)->then(
sub ($tx) {
my $access_token = $tx->result->json->{access_token};
$c->ua->get_p(
"https://$shop/admin/api/2020-01/shop.json" => {
'X-Shopify-Access-Token' => $access_token
}
)->then(
sub ($tx) {
$c->render( text => $tx->result->body );
}
)->catch( sub { die "Request failed: @_" } );
}
)->catch( sub { die "Couldn't fetch access token: @_" } );
}
else {
die 'Bad Shop in Args';
}
};
As the Shopify tutorial says in Step 6 you should now be able to run your
application, with docker-compose up
and hit it with the install link.
The banner image is The shop by OiMax, on Flickr.
This post has been edited based on feedback from people. If you want to see the changes to this or any post on this site, it’s all hosted on github.
Apparently this made it to the front page of r/programming. I was notified at the same time I was told about a typo.
Written on March 2nd , 2020 by Chris Prather