From e8bebe8c11d5ebff44b20ae6a320504ff0c29e6c Mon Sep 17 00:00:00 2001
From: Tomas Cohen Arazi <tomascohen@theke.io>
Date: Mon, 23 Jul 2018 11:14:47 -0300
Subject: [PATCH] Bug 21116: Add API routes through plugins

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, wait a bit more for those samples :-D
---
 Koha/Exceptions/Plugin.pm        |  84 +++++++++++++++++++
 Koha/REST/Plugin/PluginRoutes.pm | 140 +++++++++++++++++++++++++++++++
 Koha/REST/V1.pm                  |  37 ++++++--
 3 files changed, 254 insertions(+), 7 deletions(-)
 create mode 100644 Koha/Exceptions/Plugin.pm
 create mode 100644 Koha/REST/Plugin/PluginRoutes.pm

diff --git a/Koha/Exceptions/Plugin.pm b/Koha/Exceptions/Plugin.pm
new file mode 100644
index 0000000000..534874484b
--- /dev/null
+++ b/Koha/Exceptions/Plugin.pm
@@ -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
index 0000000000..67b7b11ebc
--- /dev/null
+++ b/Koha/REST/Plugin/PluginRoutes.pm
@@ -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 );
+    }
+
+    $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;
diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm
index 57ad113291..2c9491150a 100644
--- a/Koha/REST/V1.pm
+++ b/Koha/REST/V1.pm
@@ -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' );
     $self->plugin( 'Koha::REST::Plugin::Query' );
     $self->plugin( 'Koha::REST::Plugin::Objects' );
-- 
2.18.0