Bug 21116: Add API routes through plugins
authorTomas Cohen Arazi <tomascohen@theke.io>
Mon, 23 Jul 2018 14:14:47 +0000 (11:14 -0300)
committerFridolin Somers <fridolin.somers@biblibre.com>
Thu, 29 Nov 2018 12:26:14 +0000 (13:26 +0100)
This patch adds plugins the capability of injecting new routes on the
API.

The plugins should provide the following methods to be considered valid API-generating plugins:

- 'api_routes': returning the 'path' component of the OpenAPI specification corresponding to the routes served by the plugin
- 'api_namespace': it should return a namespace to be used for grouping the endpoints provided by the plugin

otherwise, they will be just skipped.

All plugin-generated routes will be added the 'contrib' namespace, and
will end up placed inside /contrib/<namespace>, where <namespace> is what the 'api_namespace' returns.

A sample endpoint will be added to the Kitchen Sink plugin, and tests
are being written.

To test:
- Apply this patches
- Run:
  $ kshell
 k$ prove t/db_dependent/Koha/REST/Plugin/PluginRoutes.t
=> SUCCESS: Tests pass!
- Install the (latest) KitchenSink plugin
- Point your browser to the API like this:
  http://koha-intra.myDNSname.org:8081/api/v1/.html
=> SUCCESS: The /contrib/kitchensink/patrons/:patron_id/bother endpoint
implemented by the plugin has been merged!
- Sign off! :-D

Signed-off-by: Benjamin Rokseth <benjamin.rokseth@deichman.no>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Alex Arnaud <alex.arnaud@biblibre.com>

Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
(cherry picked from commit 3fedae85f25ef5f587d567b51b86aab776d87311)
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
(cherry picked from commit ab79f3fc67a06ea7383270ae7f00f97605c3a4d0)
Signed-off-by: Fridolin Somers <fridolin.somers@biblibre.com>

Koha/Exceptions/Plugin.pm [new file with mode: 0644]
Koha/REST/Plugin/PluginRoutes.pm [new file with mode: 0644]
Koha/REST/V1.pm

diff --git a/Koha/Exceptions/Plugin.pm b/Koha/Exceptions/Plugin.pm
new file mode 100644 (file)
index 0000000..5348744
--- /dev/null
@@ -0,0 +1,84 @@
+package Koha::Exceptions::Plugin;
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+use Modern::Perl;
+
+use Koha::Exceptions::Exception;
+
+use Exception::Class (
+    'Koha::Exceptions::Plugin' => {
+        isa         => 'Koha::Exceptions::Exception',
+    },
+    'Koha::Exceptions::Plugin::ForbiddenAction' => {
+        isa         => 'Koha::Exceptions::Plugin',
+        description => 'The plugin is trying to do something it is not allowed to'
+    },
+    'Koha::Exceptions::Plugin::MissingMethod' => {
+        isa         => 'Koha::Exceptions::Plugin',
+        description => 'Required method is missing',
+        fields      =>  ['plugin_name','method']
+    }
+);
+
+sub full_message {
+    my $self = shift;
+
+    my $msg = $self->message;
+
+    unless ( $msg) {
+        if ( $self->isa('Koha::Exceptions::Plugin::MissingMethod') ) {
+            $msg = sprintf("Cannot use plugin (%s) because the it doesn't implement the '%s' method which is required.", $self->plugin_name, $self->method );
+        }
+    }
+
+    return $msg;
+}
+
+=head1 NAME
+
+Koha::Exceptions::Plugin - Base class for Plugin exceptions
+
+=head1 Exceptions
+
+=head2 Koha::Exceptions::Plugin
+
+Generic Plugin exception
+
+=head2 Koha::Exceptions::Plugin::MissingMethod
+
+Exception to be used when a plugin is required to implement a specific
+method and it doesn't.
+
+=head3 Parameters
+
+=over
+
+=item plugin_name: the plugin name for display purposes
+
+=item method: the missing method
+
+=back
+
+=head1 Class methods
+
+=head2 full_message
+
+Overloaded method for exception stringifying.
+
+=cut
+
+1;
diff --git a/Koha/REST/Plugin/PluginRoutes.pm b/Koha/REST/Plugin/PluginRoutes.pm
new file mode 100644 (file)
index 0000000..f51c7bd
--- /dev/null
@@ -0,0 +1,140 @@
+package Koha::REST::Plugin::PluginRoutes;
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+use Modern::Perl;
+
+use Mojo::Base 'Mojolicious::Plugin';
+
+use Koha::Exceptions::Plugin;
+use Koha::Plugins;
+
+use Clone qw(clone);
+use Try::Tiny;
+
+=head1 NAME
+
+Koha::REST::Plugin::PluginRoutes
+
+=head1 API
+
+=head2 Helper methods
+
+=head3 register
+
+=cut
+
+sub register {
+    my ( $self, $app, $config ) = @_;
+
+    my $spec      = $config->{spec};
+    my $validator = $config->{validator};
+
+    my @plugins;
+
+    if (   C4::Context->preference('UseKohaPlugins')
+        && C4::Context->config("enable_plugins") )
+    {
+        @plugins = Koha::Plugins->new()->GetPlugins(
+            {
+                method => 'api_routes',
+            }
+        );
+        # plugin needs to define a namespace
+        @plugins = grep { $_->api_namespace } @plugins;
+    }
+
+    foreach my $plugin ( @plugins ) {
+        $spec = inject_routes( $spec, $plugin, $validator );
+    }
+
+    return $spec;
+}
+
+=head3 inject_routes
+
+=cut
+
+sub inject_routes {
+    my ( $spec, $plugin, $validator ) = @_;
+
+    return try {
+
+        my $backup_spec = merge_spec( clone($spec), $plugin );
+        if ( spec_ok( $backup_spec, $validator ) ) {
+            $spec = merge_spec( $spec, $plugin );
+        }
+        else {
+            Koha::Exceptions::Plugin->throw(
+                "The resulting spec is invalid. Skipping " . $plugin->get_metadata->{name}
+            );
+        }
+
+        return $spec;
+    }
+    catch {
+        warn "$_";
+        return $spec;
+    };
+}
+
+=head3 merge_spec
+
+=cut
+
+sub merge_spec {
+    my ( $spec, $plugin ) = @_;
+
+    my $plugin_spec = $plugin->api_routes;
+
+    foreach my $route ( keys %{ $plugin_spec } ) {
+
+        my $THE_route = '/contrib/' . $plugin->api_namespace . $route;
+        if ( exists $spec->{ $THE_route } ) {
+            # Route exists, overwriting is forbidden
+            Koha::Exceptions::Plugin::ForbiddenAction->throw(
+                "Attempted to overwrite $THE_route"
+            );
+        }
+
+        $spec->{'paths'}->{ $THE_route } = $plugin_spec->{ $route };
+    }
+
+    return $spec;
+}
+
+=head3 spec_ok
+
+=cut
+
+sub spec_ok {
+    my ( $spec, $validator ) = @_;
+
+    return try {
+        $validator->load_and_validate_schema(
+            $spec,
+            {
+                allow_invalid_ref => 1,
+            }
+        );
+        return 1;
+    }
+    catch {
+        return 0;
+    }
+}
+
+1;
index d34cf05..16d54b0 100644 (file)
@@ -20,6 +20,7 @@ use Modern::Perl;
 use Mojo::Base 'Mojolicious';
 
 use C4::Context;
+use JSON::Validator::OpenAPI::Mojolicious;
 
 =head1 NAME
 
@@ -51,13 +52,35 @@ sub startup {
         $self->secrets([$secret_passphrase]);
     }
 
-    $self->plugin(OpenAPI => {
-        url => $self->home->rel_file("api/v1/swagger/swagger.json"),
-        route => $self->routes->under('/api/v1')->to('Auth#under'),
-        allow_invalid_ref => 1, # required by our spec because $ref directly under
-                                # Paths-, Parameters-, Definitions- & Info-object
-                                # is not allowed by the OpenAPI specification.
-    });
+    my $validator = JSON::Validator::OpenAPI::Mojolicious->new;
+    $validator->load_and_validate_schema(
+        $self->home->rel_file("api/v1/swagger/swagger.json"),
+        {
+          allow_invalid_ref  => 1,
+        }
+      );
+
+    push @{$self->routes->namespaces}, 'Koha::Plugin';
+
+    my $spec = $validator->schema->data;
+    $self->plugin(
+        'Koha::REST::Plugin::PluginRoutes' => {
+            spec      => $spec,
+            validator => $validator
+        }
+    );
+
+    $self->plugin(
+        OpenAPI => {
+            spec  => $spec,
+            route => $self->routes->under('/api/v1')->to('Auth#under'),
+            allow_invalid_ref =>
+              1,    # required by our spec because $ref directly under
+                    # Paths-, Parameters-, Definitions- & Info-object
+                    # is not allowed by the OpenAPI specification.
+        }
+    );
+
     $self->plugin( 'Koha::REST::Plugin::Pagination' );
 }