pexels-photo-3568518.jpeg

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.

One thought on “Building a microservice in Perl, part 2: Up and running

Comments are closed.