Building a microservice in Perl, part 2: Up and running

In part 1 we designed our API using OpenAPI/​Swagger. Now it’s time to write some tests and wire it up using Mojolicious::Plugin::OpenAPI. This is a much longer arti­cle; buck­le up!

If you haven’t already, you’ll need to install Perl. Using Linux or macOS? You’ve already got Perl installed on your sys­tem. On Windows? I rec­om­mend you install Strawberry Perl as it lets you devel­op Perl appli­ca­tions using the same tools that our Unix-​based brethren use. 

(Advanced users may want to inves­ti­gate using perl­brew, plenv, or berry­brew for man­ag­ing mul­ti­ple ver­sions of Perl and installing more recent ver­sions than are includ­ed on your system.)

Once you have Perl installed, it’s a sim­ple mat­ter of using either the cpan or cpanm tools to install Mojolicious and Mojolicious::Plugin::OpenAPI. The lat­ter will install some depen­den­cies as well. Here’s a tran­script of installing cpanm and the two modules:

% curl -L https://cpanmin.us | perl - App::cpanminus
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  295k  100  295k    0     0  97765      0  0:00:03  0:00:03 --:--:-- 97765
--> Working on App::cpanminus
Fetching http://www.cpan.org/authors/id/M/MI/MIYAGAWA/App-cpanminus-1.7044.tar.gz ... OK
Configuring App-cpanminus-1.7044 ... OK
Building and testing App-cpanminus-1.7044 ... OK
Successfully installed App-cpanminus-1.7044
1 distribution installed
% cpanm Mojolicious Mojolicious::Plugin::OpenAPI
--> Working on Mojolicious
Fetching http://www.cpan.org/authors/id/S/SR/SRI/Mojolicious-9.01.tar.gz ... OK
Configuring Mojolicious-9.01 ... OK
Building and testing Mojolicious-9.01 ... OK
Successfully installed Mojolicious-9.01
--> Working on Mojolicious::Plugin::OpenAPI
Fetching http://www.cpan.org/authors/id/J/JH/JHTHORSEN/Mojolicious-Plugin-OpenAPI-4.00.tar.gz ... OK
Configuring Mojolicious-Plugin-OpenAPI-4.00 ... OK
==> Found dependencies: JSON::Validator
--> Working on JSON::Validator
Fetching http://www.cpan.org/authors/id/J/JH/JHTHORSEN/JSON-Validator-4.14.tar.gz ... OK
Configuring JSON-Validator-4.14 ... OK
==> Found dependencies: YAML::PP, Test::Deep
--> Working on YAML::PP
Fetching http://www.cpan.org/authors/id/T/TI/TINITA/YAML-PP-0.026.tar.gz ... OK
Configuring YAML-PP-0.026 ... OK
==> Found dependencies: Test::Warn, Test::Deep
--> Working on Test::Warn
Fetching http://www.cpan.org/authors/id/B/BI/BIGJ/Test-Warn-0.36.tar.gz ... OK
Configuring Test-Warn-0.36 ... OK
==> Found dependencies: Sub::Uplevel
--> Working on Sub::Uplevel
Fetching http://www.cpan.org/authors/id/D/DA/DAGOLDEN/Sub-Uplevel-0.2800.tar.gz ... OK
Configuring Sub-Uplevel-0.2800 ... OK
Building and testing Sub-Uplevel-0.2800 ... OK
Successfully installed Sub-Uplevel-0.2800
Building and testing Test-Warn-0.36 ... OK
Successfully installed Test-Warn-0.36
--> Working on Test::Deep
Fetching http://www.cpan.org/authors/id/R/RJ/RJBS/Test-Deep-1.130.tar.gz ... OK
Configuring Test-Deep-1.130 ... OK
Building and testing Test-Deep-1.130 ... OK
Successfully installed Test-Deep-1.130
Building and testing YAML-PP-0.026 ... OK
Successfully installed YAML-PP-0.026
Building and testing JSON-Validator-4.14 ... OK
Successfully installed JSON-Validator-4.14
Building and testing Mojolicious-Plugin-OpenAPI-4.00 ... OK
Successfully installed Mojolicious-Plugin-OpenAPI-4.00
7 distributions installed

(The ver­sions list­ed above may dif­fer as this arti­cle gets pro­gres­sive­ly out of date.)

Next, make a fold­er some­where to place our new microser­vice project. We’ll call it ~/Projects/blog here, but any place will do as long as you know how to get to it from your text edi­tor and your com­mand line.

After that, go into that direc­to­ry and use the newly-​installed mojo com­mand to build the basics of our project:

% cd ~/Projects/blog
% mojo generate app Local::Dictionary::Microservice
  [mkdir] /Users/mgardner/Projects/blog/local_dictionary_microservice/script
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/script/local_dictionary_microservice
  [chmod] /Users/mgardner/Projects/blog/local_dictionary_microservice/script/local_dictionary_microservice 744
  [mkdir] /Users/mgardner/Projects/blog/local_dictionary_microservice/lib/Local/Dictionary
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/lib/Local/Dictionary/Microservice.pm
  [exist] /Users/mgardner/Projects/blog/local_dictionary_microservice
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/local-dictionary-microservice.yml
  [mkdir] /Users/mgardner/Projects/blog/local_dictionary_microservice/lib/Local/Dictionary/Microservice/Controller
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/lib/Local/Dictionary/Microservice/Controller/Example.pm
  [mkdir] /Users/mgardner/Projects/blog/local_dictionary_microservice/t
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/t/basic.t
  [mkdir] /Users/mgardner/Projects/blog/local_dictionary_microservice/public
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/public/index.html
  [mkdir] /Users/mgardner/Projects/local_dictionary_microservice/templates/layouts
  [write] /Users/mgardner/Projects/local_dictionary_microservice/templates/layouts/default.html.ep
  [mkdir] /Users/mgardner/Projects/blog/local_dictionary_microservice/templates/example
  [write] /Users/mgardner/Projects/blog/local_dictionary_microservice/templates/example/welcome.html.ep

This com­mand builds the scaf­fold­ing for our new project. We won’t be using all of it, but it does pro­vide some use­ful start­ing points.

(Why did we put Local:: in the begin­ning of the name? Because noth­ing you place there will con­flict with oth­er mod­ules you install from CPAN.)

Next, put the OpenAPI doc­u­ment we devel­oped in part 1 some­where in the project. We’ll choose openapi/dictionary_openapi.yml. Create a fold­er called openapi in the project, and then open your text edi­tor and paste the fol­low­ing doc­u­ment into a file called dictionary_openapi.yml.

openapi: 3.0.3
info:
  title: Dictionary
  description: The PhoenixTrap.com dictionary microservice
  version: 1.0.0
  license:
    name: Artistic License 2.0
    url: https://www.perlfoundation.org/artistic-license-20.html
paths:
  /health:
    get:
      summary: Check if this service is online
      x-mojo-to: monitoring#heartbeat
      responses:
        200:
          description: All systems operational
          content:
            application/json:
              schema:
                type: object
        500:
          description: Something is wrong
  /word/{word}:
    get:
      summary: Get the definition of a word
      x-mojo-to: word#define
      parameters:
      - $ref: '#/components/parameters/word'
      responses:
        200:
          description: Found word
          content:
            application/json:
              schema:
                type: string
        404:
          description: Could not find word
    post:
      summary: Add or replace the definition of a word
      x-mojo-to: word#save
      parameters:
      - $ref: '#/components/parameters/word'
      requestBody:
        description: Definition of a word
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                definition:
                  type: string
      responses:
        200:
          description: Word saved
    delete:
      summary: Delete an entry from the dictionary
      x-mojo-to: word#remove
      parameters:
      - $ref: '#/components/parameters/word'
      responses:
        200:
          description: Word deleted
components:
  parameters:
    word:
      description: A word in the dictionary
      in: path
      name: word
      required: true
      schema:
        type: string

Calling the plugin

Right now all our appli­ca­tion does is serve a demon­stra­tion page; you can test it by run­ning the fol­low­ing command:

% script/local_dictionary_microservice daemon
[2021-02-28 16:17:09.76863] [74167] [info] Listening at "http://*:3000"
Web application available at http://127.0.0.1:3000

…and then going to that URL on the last line.

After you’ve ver­i­fied that it works, hold down the Control key and type the let­ter C in your ter­mi­nal to stop the script. Edit the lib/Local/Dictionary/Microservice.pm file in your text edi­tor. Replace it with this:

package Local::Dictionary::Microservice;
use Mojo::Base 'Mojolicious', -signatures;

# This method will run once at server start
sub startup($self) {

    # Load configuration from config file
    my $config = $self->plugin('NotYAMLConfig');

    # Configure the application
    $self->secrets( $config->{secrets} );
    $self->plugin( OpenAPI => {
        url => $self->home
          ->rel_file('openapi/dictionary_openapi.yml'),
    } );
}

1;

Now when you run the script as a dae­mon, the URL it reports responds with the JSON ver­sion of our OpenAPI doc­u­ment. Progress!

Note the $self->plugin() call towards the end of our class above. That’s the secret sauce that loads our OpenAPI doc­u­ment and tells Mojolicious to cre­ate respons­es from it.

Writing our first tests

Next, we’ll write a cou­ple of test scripts. (Why are we writ­ing tests before actu­al­ly cod­ing our microser­vice? Because we’re prac­tic­ing test-​driven devel­op­ment, in which we write our tests first, see that they fail, and then write the code to make them suc­ceed.) Here’s a mod­i­fied t/basic.t script that should suc­ceed right off the bat:

use Mojo::Base -strict;

use Test::More;
use Test::Mojo;

my $t = Test::Mojo->new('Local::Dictionary::Microservice');
$t->get_ok('/')->status_is(200)->json_has('/openapi');

done_testing();

Run it with prove:

% prove -vl t/basic.t
t/basic.t .. [2021-02-28 16:32:54.79287] [74611] [debug] [3kzjyqgi] GET "/"
[2021-02-28 16:32:54.79321] [74611] [debug] [3kzjyqgi] Routing to a callback
[2021-02-28 16:32:54.79536] [74611] [debug] [3kzjyqgi] 200 OK (0.002493s, 401.123/s)
ok 1 - GET /
ok 2 - 200 OK
ok 3 - has value for JSON Pointer "/openapi"
1..3
ok
All tests successful.
Files=1, Tests=3,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.45 cusr  0.07 csys =  0.55 CPU)
Result: PASS

Now for a test that will fail at first. Put this in t/word.t:

use Mojo::Base -strict;

use Test::More;
use Test::Mojo;

my $t = Test::Mojo->new('Local::Dictionary::Microservice');

$t->post_ok(
    '/word/foo' =>
      form => { definition => 'A metasyntactic variable' },
)->status_is(200);

done_testing();

And prove it:

% prove -vl t/word.t
t/word.t .. [2021-02-28 16:48:53.26826] [75751] [debug] [SlDSHWVE] POST "/word/foo"
[2021-02-28 16:48:53.26897] [75751] [debug] [SlDSHWVE] Routing to controller "Local::Dictionary::Microservice::Controller::Word" and action "save"
[2021-02-28 16:48:53.27278] [75751] [debug] [SlDSHWVE] Template "word/save.html.ep" not found
[2021-02-28 16:48:53.27287] [75751] [debug] [SlDSHWVE] Nothing has been rendered, expecting delayed response
[2021-02-28 16:49:23.27553] [75751] [debug] Inactivity timeout
# Premature connection close
not ok 1 - POST /word/foo
not ok 2 - 200 OK
#   Failed test 'POST /word/foo'
#   at t/word.t line 8.
#   Failed test '200 OK'
#   at t/word.t line 8.
#          got: undef
#     expected: '200'
1..2
# Looks like you failed 2 tests of 2.
Dubious, test returned 2 (wstat 512, 0x200)
Failed 2/2 subtests
Test Summary Report
-------------------
t/word.t (Wstat: 512 Tests: 2 Failed: 2)
  Failed tests:  1-2
  Non-zero exit status: 2
Files=1, Tests=2, 31 wallclock secs ( 0.02 usr  0.01 sys +  0.45 cusr  0.07 csys =  0.55 CPU)
Result: FAIL

Note in the sec­ond line of out­put that Mojolicious tried to route to a con­troller class at Local::Dictionary::Microservice::Controller::Word with an action of save. That’s what our OpenAPI doc­u­ment told it to do with its x-mojo-to: word#save line.

Adding methods to make the tests pass

Now we’ll write the con­troller class at lib/Local/Dictionary/Microservice/Controller/Word.pm:

package Local::Dictionary::Microservice::Controller::Word;
use Mojo::Base 'Mojolicious::Controller', -signatures;
use DB_File;

sub save($self) {
    return if not $self->openapi->valid_input;
    my ( $word, $definition ) = map { $self->param($_) }
      qw(word definition);

    tie my %definition, 'DB_File',
      $self->app->home->child('definitions.db');
    $definition{$word} = $definition;

    return $self->render( openapi => {} );
}

1;

The save method above retrieves the word and definition para­me­ters from the URL path and POST data, respec­tive­ly, and then saves them into a hash that is tied to a Berkeley DB file stored in definitions.db. It then ren­ders an emp­ty response back to the client.

Now run the test:

% prove -vl t/word.t
t/word.t .. [2021-02-28 18:06:33.77551] [78923] [debug] [VV0L-5rU] POST "/word/foo"
[2021-02-28 18:06:33.77623] [78923] [debug] [VV0L-5rU] Routing to controller "Local::Dictionary::Microservice::Controller::Word" and action "save"
[2021-02-28 18:06:33.77751] [78923] [debug] [VV0L-5rU] 200 OK (0.001994s, 501.505/s)
ok 1 - POST /word/foo
ok 2 - 200 OK
1..2
ok
All tests successful.
Files=1, Tests=2,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.43 cusr  0.07 csys =  0.53 CPU)
Result: PASS

Woohoo, our method works! Let’s add some tests to the same t/word.t script, under­neath the first one:


$t->get_ok('/word/foo')->status_is(200)
  ->content_is('"A metasyntactic variable"');

$t->delete_ok('/word/foo')->status_is(200);

$t->get_ok('/word/foo')->status_is(404);

Here we’re test­ing that we get back the def­i­n­i­tion we saved, that we can delete the def­i­n­i­tion, and that when we try to retrieve it again we get a 404 Not Found error.

The test script will fail again, but we can fix that by adding the define and remove meth­ods to our con­troller class in lib/Local/Dictionary/Microservice/Controller/Word.pm:

sub define($self) {
    return if not $self->openapi->valid_input;
    my $word = $self->param('word');

    tie my %definition, 'DB_File',
      $self->app->home->child('definitions.db');

    return $self->render( openapi => $definition{$word},
      exists $definition{$word} ? () : (status => 404),
    );
}

sub remove($self) {
    return if not $self->openapi->valid_input;
    my $word = $self->param('word');

    tie my %definition, 'DB_File',
      $self->app->home->child('definitions.db');
    delete $definition{$word};

    return $self->render( openapi => {} );
}

Run the test script one last time:

% prove -vl t/word.t
t/word.t .. [2021-02-28 18:13:50.82904] [79162] [debug] [7PprxDOz] POST "/word/foo"
[2021-02-28 18:13:50.82978] [79162] [debug] [7PprxDOz] Routing to controller "Local::Dictionary::Microservice::Controller::Word" and action "save"
[2021-02-28 18:13:50.83225] [79162] [debug] [7PprxDOz] 200 OK (0.003191s, 313.381/s)
ok 1 - POST /word/foo
ok 2 - 200 OK
[2021-02-28 18:13:50.83531] [79162] [debug] [G0qyFA0G] GET "/word/foo"
[2021-02-28 18:13:50.83568] [79162] [debug] [G0qyFA0G] Routing to controller "Local::Dictionary::Microservice::Controller::Word" and action "define"
[2021-02-28 18:13:50.85745] [79162] [debug] [G0qyFA0G] 200 OK (0.022096s, 45.257/s)
ok 3 - GET /word/foo
ok 4 - 200 OK
ok 5 - exact match for content
[2021-02-28 18:13:50.86182] [79162] [debug] [MoedWcOi] DELETE "/word/foo"
[2021-02-28 18:13:50.86224] [79162] [debug] [MoedWcOi] Routing to controller "Local::Dictionary::Microservice::Controller::Word" and action "remove"
[2021-02-28 18:13:50.86337] [79162] [debug] [MoedWcOi] 200 OK (0.001535s, 651.466/s)
ok 6 - DELETE /word/foo
ok 7 - 200 OK
[2021-02-28 18:13:50.86662] [79162] [debug] [G2lH8gyw] GET "/word/foo"
[2021-02-28 18:13:50.86684] [79162] [debug] [G2lH8gyw] Routing to controller "Local::Dictionary::Microservice::Controller::Word" and action "define"
[2021-02-28 18:13:50.86964] [79162] [debug] [G2lH8gyw] 404 Not Found (0.002989s, 334.560/s)
ok 8 - GET /word/foo
ok 9 - 404 Not Found
1..9
ok
All tests successful.
Files=1, Tests=9,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.46 cusr  0.10 csys =  0.59 CPU)
Result: PASS

Congratulations! You now have a sim­ple microser­vice for stor­ing and retriev­ing def­i­n­i­tions of words. You can now write a web front-​end in HTML and JavaScript, or per­haps anoth­er microser­vice that con­sumes this one.

(As an exer­cise, write the /health route defined in our OpenAPI doc­u­ment. It calls a heartbeat method in a con­troller class named Monitoring.)

What next?

  • You may not want to store your def­i­n­i­tions in a DB file in your project; con­sid­er mak­ing it a con­fig­urable option. Or use one of the many mod­ules on CPAN to choose a com­plete­ly dif­fer­ent back­end.
  • Add meth­ods to list some or all def­i­n­i­tions, using hyper­text as the engine of appli­ca­tion state (HATEOAS) to ren­der links where appropriate.
  • Support mul­ti­ple def­i­n­i­tions of the same word, or expand the API to respond with syn­onyms and antonyms. (Just make sure to incre­ment the ver­sion in the OpenAPI doc­u­ment and add some way for clients to spec­i­fy the ver­sion to indi­cate you’re mak­ing a break­ing change!)

Did you have any trou­ble fol­low­ing along? Got stuck on the instal­la­tion steps or some­where else? Please leave a com­ment below and I’ll try to help.


Discover more from The Phoenix Trap

Subscribe to get the latest posts sent to your email.

Mark Gardner Avatar

Hi, I’m Mark.

Hi, I’m Mark Gard­ner, and this is my personal blog. I show software developers how to level up by building production-ready things that work. Clear code, real projects, lessons learned.

Comments

One response to “Building a microservice in Perl, part 2: Up and running”

  1. […] the next install­ment, we’ll actu­al­ly be writ­ing some Perl by build­ing our Mojo application […]

To respond on your own website, enter the URL of your response which should contain a link to this post's permalink URL. Your response will then appear (possibly after moderation) on this page. Want to update or remove your response? Update or delete your post and re-enter your post's URL again. (Find out more about Webmentions.)