Bug 16497: Add /api/v1/libraries
authorJiří Kozlovský <mail@jkozlovsky.cz>
Sun, 31 Jul 2016 09:46:57 +0000 (11:46 +0200)
committerroot <root@f1ebe1bec408>
Tue, 19 Feb 2019 13:52:14 +0000 (13:52 +0000)
CRUD for libraries via REST API.

GET    /api/v1/libraries              - List all libraries
GET    /api/v1/libraries/{branchcode} - Get one Library
POST   /api/v1/libraries              - Add new Library
DELETE /api/v1/libraries/{branchcode} - Delete Library

Test plan:
  - apply patch
  - run tests: t/db_dependent/api/v1/libraries.t
  - test API with some API tool or simple curl

e.g.:
curl http://host:port/api/v1/libraries
curl http://host:port/api/v1/libraries/cpl

Signed-off-by: Josef Moravec <josef.moravec@gmail.com>
Signed-off-by: Benjamin Rokseth <benjamin.rokseth@kul.oslo.kommune.no>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Josef Moravec <josef.moravec@gmail.com>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Koha/REST/V1/Library.pm [new file with mode: 0644]
api/v1/swagger/definitions.json
api/v1/swagger/definitions/library.json [new file with mode: 0644]
api/v1/swagger/parameters.json
api/v1/swagger/parameters/library.json [new file with mode: 0644]
api/v1/swagger/paths.json
api/v1/swagger/paths/libraries.json [new file with mode: 0644]
api/v1/swagger/x-primitives.json
t/db_dependent/api/v1/libraries.t [new file with mode: 0644]

diff --git a/Koha/REST/V1/Library.pm b/Koha/REST/V1/Library.pm
new file mode 100644 (file)
index 0000000..9772217
--- /dev/null
@@ -0,0 +1,141 @@
+package Koha::REST::V1::Library;
+
+# 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::Controller';
+use Koha::Libraries;
+
+use Scalar::Util qw( blessed );
+
+use Try::Tiny;
+
+sub list {
+    my $c = shift->openapi->valid_input or return;
+
+    my $libraries;
+    my $filter;
+    my $args = $c->req->params->to_hash;
+
+    for my $filter_param ( keys %$args ) {
+        $filter->{$filter_param} = { LIKE => $args->{$filter_param} . "%" };
+    }
+
+    return try {
+        my $libraries = Koha::Libraries->search($filter);
+        return $c->render( status => 200, openapi => $libraries );
+    }
+    catch {
+        if ( $_->isa('DBIx::Class::Exception') ) {
+            return $c->render( status  => 500,
+                               openapi => { error => $_->{msg} } );
+        }
+        else {
+            return $c->render( status => 500,
+                openapi => { error => "Something went wrong, check the logs."} );
+        }
+    };
+}
+
+sub get {
+    my $c = shift->openapi->valid_input or return;
+
+    my $branchcode = $c->validation->param('branchcode');
+    my $library = Koha::Libraries->find({ branchcode => $branchcode });
+    unless ($library) {
+        return $c->render( status  => 404,
+                           openapi => { error => "Library not found" } );
+    }
+
+    return $c->render( status => 200, openapi => $library );
+}
+
+sub add {
+    my $c = shift->openapi->valid_input or return;
+
+    return try {
+        if (Koha::Libraries->find($c->req->json->{branchcode})) {
+            return $c->render( status => 400,
+                openapi => { error => 'Library already exists' } );
+        }
+        my $library = Koha::Library->new($c->validation->param('body'))->store;
+        my $branchcode = $library->branchcode;
+        $c->res->headers->location($c->req->url->to_string.'/'.$branchcode);
+        return $c->render( status => 201, openapi => $library);
+    }
+    catch {
+        if ( $_->isa('DBIx::Class::Exception') ) {
+            return $c->render( status  => 500,
+                               openapi => { error => $_->{msg} } );
+        }
+        else {
+            return $c->render( status => 500,
+                openapi => { error => "Something went wrong, check the logs."} );
+        }
+    };
+}
+
+sub update {
+    my $c = shift->openapi->valid_input or return;
+
+    my $library;
+    return try {
+        $library = Koha::Libraries->find($c->validation->param('branchcode'));
+        $library->set($c->validation->param('body'))->store;
+        return $c->render( status => 200, openapi => $library );
+    }
+    catch {
+        if ( not defined $library ) {
+            return $c->render( status => 404,
+                               openapi => { error => "Object not found" });
+        }
+        elsif ( $_->isa('DBIx::Class::Exception') ) {
+            return $c->render( status  => 500,
+                               openapi => { error => $_->{msg} } );
+        }
+        else {
+            return $c->render( status => 500,
+                openapi => { error => "Something went wrong, check the logs."} );
+        }
+    };
+}
+
+sub delete {
+    my $c = shift->openapi->valid_input or return;
+
+    my $library;
+    return try {
+        $library = Koha::Libraries->find($c->validation->param('branchcode'));
+        $library->delete;
+        return $c->render( status => 204, openapi => '');
+    }
+    catch {
+        if ( not defined $library ) {
+            return $c->render( status => 404, openapi => { error => "Object not found" } );
+        }
+        elsif ( $_->isa('DBIx::Class::Exception') ) {
+            return $c->render( status  => 500,
+                               openapi => { error => $_->{msg} } );
+        }
+        else {
+            return $c->render( status => 500,
+                openapi => { error => "Something went wrong, check the logs."} );
+        }
+    };
+}
+
+1;
index 6509432..95f91e0 100644 (file)
@@ -14,6 +14,9 @@
   "holds": {
     "$ref": "definitions/holds.json"
   },
+  "library": {
+    "$ref": "definitions/library.json"
+  },
   "patron": {
     "$ref": "definitions/patron.json"
   },
diff --git a/api/v1/swagger/definitions/library.json b/api/v1/swagger/definitions/library.json
new file mode 100644 (file)
index 0000000..e16390c
--- /dev/null
@@ -0,0 +1,86 @@
+{
+  "type": "object",
+  "properties": {
+    "branchcode": {
+      "$ref": "../x-primitives.json#/branchcode"
+    },
+    "branchname": {
+      "type": "string",
+      "description": "Printable name of library"
+    },
+    "branchaddress1": {
+      "type": ["string", "null"],
+      "description": "the first address line of the library"
+    },
+    "branchaddress2": {
+      "type": ["string", "null"],
+      "description": "the second address line of the library"
+    },
+    "branchaddress3": {
+      "type": ["string", "null"],
+      "description": "the third address line of the library"
+    },
+    "branchzip": {
+      "type": ["string", "null"],
+      "description": "the zip or postal code of the library"
+    },
+    "branchcity": {
+      "type": ["string", "null"],
+      "description": "the city or province of the library"
+    },
+    "branchstate": {
+      "type": ["string", "null"],
+      "description": "the reqional state of the library"
+    },
+    "branchcountry": {
+      "type": ["string", "null"],
+      "description": "the county of the library"
+    },
+    "branchphone": {
+      "type": ["string", "null"],
+      "description": "the primary phone of the library"
+    },
+    "branchfax": {
+      "type": ["string", "null"],
+      "description": "the fax number of the library"
+    },
+    "branchemail": {
+      "type": ["string", "null"],
+      "description": "the primary email address of the library"
+    },
+    "branchreplyto": {
+      "type": ["string", "null"],
+      "description": "the email to be used as a Reply-To"
+    },
+    "branchreturnpath": {
+      "type": ["string", "null"],
+      "description": "the email to be used as Return-Path"
+    },
+    "branchurl": {
+      "type": ["string", "null"],
+      "description": "the URL for your library or branch's website"
+    },
+    "issuing": {
+      "type": ["integer", "null"],
+      "description": "unused in Koha"
+    },
+    "branchip": {
+      "type": ["string", "null"],
+      "description": "the IP address for your library or branch"
+    },
+    "branchprinter": {
+      "type": ["string", "null"],
+      "description": "unused in Koha"
+    },
+    "branchnotes": {
+      "type": ["string", "null"],
+      "description": "notes related to your library or branch"
+    },
+    "opac_info": {
+      "type": ["string", "null"],
+      "description": "HTML that displays in OPAC"
+    }
+  },
+  "additionalProperties": false,
+  "required": ["branchcode", "branchname"]
+}
index d951cf7..1d5feba 100644 (file)
@@ -8,6 +8,9 @@
   "city_id_pp": {
     "$ref": "parameters/city.json#/city_id_pp"
   },
+  "branchcodePathParam": {
+    "$ref": "parameters/library.json#/branchcodePathParam"
+  },
   "holdIdPathParam": {
     "$ref": "parameters/hold.json#/holdIdPathParam"
   },
diff --git a/api/v1/swagger/parameters/library.json b/api/v1/swagger/parameters/library.json
new file mode 100644 (file)
index 0000000..366581e
--- /dev/null
@@ -0,0 +1,9 @@
+{
+  "branchcodePathParam": {
+    "name": "branchcode",
+    "in": "path",
+    "description": "Branch identifier code",
+    "required": true,
+    "type": "string"
+  }
+}
index b41ef9e..077ebdd 100644 (file)
   "/holds/{reserve_id}": {
     "$ref": "paths/holds.json#/~1holds~1{reserve_id}"
   },
+  "/libraries": {
+    "$ref": "paths/libraries.json#/~1libraries"
+  },
+  "/libraries/{branchcode}": {
+    "$ref": "paths/libraries.json#/~1libraries~1{branchcode}"
+  },
   "/patrons": {
     "$ref": "paths/patrons.json#/~1patrons"
   },
diff --git a/api/v1/swagger/paths/libraries.json b/api/v1/swagger/paths/libraries.json
new file mode 100644 (file)
index 0000000..1457788
--- /dev/null
@@ -0,0 +1,362 @@
+{
+  "/libraries": {
+    "get": {
+      "x-mojo-to": "Library#list",
+      "operationId": "listLibrary",
+      "tags": ["library"],
+      "parameters": [{
+        "name": "branchname",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on name",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchaddress1",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on address1",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchaddress2",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on address2",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchaddress3",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on address3",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchzip",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on zipcode",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchcity",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on city",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchstate",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on state",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchcountry",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on country",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchphone",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on phone number",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchfax",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on fax number",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchemail",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on email address",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchreplyto",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on Reply-To email address",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchreturnpath",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on Return-Path email address",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchurl",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on website URL",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "issuing",
+        "in": "query",
+        "description": "Unused in Koha",
+        "required": false,
+        "type": "integer"
+      }, {
+        "name": "branchip",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on IP address",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchprinter",
+        "in": "query",
+        "description": "Unused in Koha",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "branchnotes",
+        "in": "query",
+        "description": "Case insensitive 'starts_with' search on notes",
+        "required": false,
+        "type": "string"
+      }, {
+        "name": "opac_info",
+        "in": "query",
+        "description": "Case insensitive 'starts-with' search on OPAC info",
+        "required": false,
+        "type": "string"
+      }],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "200": {
+          "description": "A list of libraries",
+          "schema": {
+            "type": "array",
+            "items": {
+              "$ref": "../definitions.json#/library"
+            }
+          }
+        },
+        "500": {
+          "description": "Internal error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      }
+    },
+    "post": {
+      "x-mojo-to": "Library#add",
+      "operationId": "addLibrary",
+      "tags": ["library"],
+      "parameters": [{
+        "name": "body",
+        "in": "body",
+        "description": "A JSON object containing informations about the new library",
+        "required": true,
+        "schema": {
+          "$ref": "../definitions.json#/library"
+        }
+      }],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "201": {
+          "description": "Library added",
+          "schema": {
+            "$ref": "../definitions.json#/library"
+          }
+        },
+        "400": {
+          "description": "Bad request",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "401": {
+          "description": "Authentication required",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "403": {
+          "description": "Access forbidden",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "500": {
+          "description": "Internal error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "parameters": "parameters_remaining_permissions"
+        }
+      }
+    }
+  },
+  "/libraries/{branchcode}": {
+    "get": {
+      "x-mojo-to": "Library#get",
+      "operationId": "getLibrary",
+      "tags": ["library"],
+      "parameters": [
+        {
+          "$ref": "../parameters.json#/branchcodePathParam"
+        }
+      ],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "200": {
+          "description": "A library",
+          "schema": {
+            "$ref": "../definitions.json#/library"
+          }
+        },
+        "404": {
+          "description": "Library not found",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      }
+    },
+    "put": {
+      "x-mojo-to": "Library#update",
+      "operationId": "updateLibrary",
+      "tags": ["library"],
+      "parameters": [{
+        "$ref": "../parameters.json#/branchcodePathParam"
+      }, {
+        "name": "body",
+        "in": "body",
+        "description": "A JSON object containing information on the library",
+        "required": true,
+        "schema": {
+          "$ref": "../definitions.json#/library"
+        }
+      }],
+      "consumes": [
+        "application/json"
+      ],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "200": {
+          "description": "A library",
+          "schema": {
+            "$ref": "../definitions.json#/library"
+          }
+        },
+        "400": {
+          "description": "Bad request",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "401": {
+          "description": "Authentication required",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "403": {
+          "description": "Access forbidden",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "404": {
+          "description": "Library not found",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "500": {
+          "description": "Internal error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "parameters": "parameters_remaining_permissions"
+        }
+      }
+    },
+    "delete": {
+      "x-mojo-to": "Library#delete",
+      "operationId": "deleteLibrary",
+      "tags": ["library"],
+      "parameters": [{
+        "$ref": "../parameters.json#/branchcodePathParam"
+      }],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "204": {
+          "description": "Library deleted",
+          "schema": { "type": "string" }
+        },
+        "401": {
+          "description": "Authentication required",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "403": {
+          "description": "Access forbidden",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "404": {
+          "description": "Library not found",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "500": {
+          "description": "Internal error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "parameters": "parameters_remaining_permissions"
+        }
+      }
+    }
+  }
+}
index 0266297..9b077d6 100644 (file)
@@ -7,6 +7,12 @@
     "type": "integer",
     "description": "Internal patron identifier"
   },
+  "branchcode": {
+    "type": "string",
+    "description": "internally assigned library identifier",
+    "maxLength": 10,
+    "minLength": 1
+  },
   "cardnumber": {
     "type": ["string", "null"],
     "description": "library assigned user identifier"
diff --git a/t/db_dependent/api/v1/libraries.t b/t/db_dependent/api/v1/libraries.t
new file mode 100644 (file)
index 0000000..7b59610
--- /dev/null
@@ -0,0 +1,413 @@
+#!/usr/bin/env perl
+
+# 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 Test::More tests => 5;
+use Test::Mojo;
+use Test::Warn;
+
+use t::lib::TestBuilder;
+use t::lib::Mocks;
+
+use C4::Auth;
+use Koha::Libraries;
+use Koha::Database;
+
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new;
+
+# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling
+# this affects the other REST api tests
+t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
+
+my $remote_address = '127.0.0.1';
+my $t              = Test::Mojo->new('Koha::REST::V1');
+
+subtest 'list() tests' => sub {
+    plan tests => 8;
+
+    $schema->storage->txn_begin;
+
+    # Create test context
+    my $library = $builder->build( { source => 'Branch' } );
+    my $another_library = { %$library };   # create a copy of $library but make
+    delete $another_library->{branchcode}; # sure branchcode will be regenerated
+    $another_library = $builder->build(
+        { source => 'Branch', value => $another_library } );
+    my ( $borrowernumber, $session_id ) =
+      create_user_and_session( { authorized => 0 } );
+
+    ## Authorized user tests
+    my $count_of_libraries = Koha::Libraries->search->count;
+    # Make sure we are returned with the correct amount of libraries
+    my $tx = $t->ua->build_tx( GET => '/api/v1/libraries' );
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(200)
+      ->json_has('/'.($count_of_libraries-1).'/branchcode')
+      ->json_hasnt('/'.($count_of_libraries).'/branchcode');
+
+    subtest 'query parameters' => sub {
+        my @fields = qw(
+        branchname       branchaddress1 branchaddress2 branchaddress3
+        branchzip        branchcity     branchstate    branchcountry
+        branchphone      branchfax      branchemail    branchreplyto
+        branchreturnpath branchurl      issuing        branchip
+        branchprinter    branchnotes    opac_info
+        );
+        plan tests => scalar(@fields)*3;
+
+        foreach my $field (@fields) {
+            $tx = $t->ua->build_tx( GET =>
+                         "/api/v1/libraries?$field=$library->{$field}" );
+            $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+            $tx->req->env( { REMOTE_ADDR => $remote_address } );
+            my $result =
+            $t->request_ok($tx)
+              ->status_is(200)
+              ->json_has( [ $library, $another_library ] );
+        }
+    };
+
+    # Warn on unsupported query parameter
+    $tx = $t->ua->build_tx( GET => '/api/v1/libraries?library_blah=blah' );
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(400)
+      ->json_is( [{ path => '/query/library_blah', message => 'Malformed query string'}] );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'get() tests' => sub {
+
+    plan tests => 6;
+
+    $schema->storage->txn_begin;
+
+    my $library = $builder->build( { source => 'Branch' } );
+    my ( $borrowernumber, $session_id ) =
+      create_user_and_session( { authorized => 0 } );
+
+    my $tx = $t->ua->build_tx( GET => "/api/v1/libraries/" . $library->{branchcode} );
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(200)
+      ->json_is($library);
+
+    my $non_existent_code = 'non_existent'.int(rand(10000));
+    $tx = $t->ua->build_tx( GET => "/api/v1/libraries/" . $non_existent_code );
+    $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(404)
+      ->json_is( '/error' => 'Library not found' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'add() tests' => sub {
+    plan tests => 31;
+
+    $schema->storage->txn_begin;
+
+    my ( $unauthorized_borrowernumber, $unauthorized_session_id ) =
+      create_user_and_session( { authorized => 0 } );
+    my ( $authorized_borrowernumber, $authorized_session_id ) =
+      create_user_and_session( { authorized => 1 } );
+    my $library = {
+        branchcode       => "LIBRARYBR1",
+        branchname       => "Library Name",
+        branchaddress1   => "Library Address1",
+        branchaddress2   => "Library Address2",
+        branchaddress3   => "Library Address3",
+        branchzip        => "Library Zipcode",
+        branchcity       => "Library City",
+        branchstate      => "Library State",
+        branchcountry    => "Library Country",
+        branchphone      => "Library Phone",
+        branchfax        => "Library Fax",
+        branchemail      => "Library Email",
+        branchreplyto    => "Library Reply-To",
+        branchreturnpath => "Library Return-Path",
+        branchurl        => "http://library.url",
+        issuing          => undef,                  # unused in Koha
+        branchip         => "127.0.0.1",
+        branchprinter    => "Library Printer",      # unused in Koha
+        branchnotes      => "Library Notes",
+        opac_info        => "<p>Library OPAC info</p>",
+    };
+
+    # Unauthorized attempt to write
+    my $tx = $t->ua->build_tx( POST => "/api/v1/libraries" => json => $library );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $unauthorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(403);
+
+    # Authorized attempt to write invalid data
+    my $library_with_invalid_field = { %$library };
+    $library_with_invalid_field->{'branchinvalid'} = 'Library invalid';
+
+    $tx = $t->ua->build_tx(
+        POST => "/api/v1/libraries" => json => $library_with_invalid_field );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(400)
+      ->json_is(
+        "/errors" => [
+            {
+                message => "Properties not allowed: branchinvalid.",
+                path    => "/body"
+            }
+        ]
+    );
+
+    # Authorized attempt to write
+    $tx = $t->ua->build_tx( POST => "/api/v1/libraries" => json => $library );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    my $branchcode = $t->request_ok($tx)
+      ->status_is(201)
+      ->json_is( '/branchname'       => $library->{branchname} )
+      ->json_is( '/branchaddress1'   => $library->{branchaddress1} )
+      ->json_is( '/branchaddress2'   => $library->{branchaddress2} )
+      ->json_is( '/branchaddress3'   => $library->{branchaddress3} )
+      ->json_is( '/branchzip'        => $library->{branchzip} )
+      ->json_is( '/branchcity'       => $library->{branchcity} )
+      ->json_is( '/branchstate'      => $library->{branchstate} )
+      ->json_is( '/branchcountry'    => $library->{branchcountry} )
+      ->json_is( '/branchphone'      => $library->{branchphone} )
+      ->json_is( '/branchfax'        => $library->{branchfax} )
+      ->json_is( '/branchemail'      => $library->{branchemail} )
+      ->json_is( '/branchreplyto'    => $library->{branchreplyto} )
+      ->json_is( '/branchreturnpath' => $library->{branchreturnpath} )
+      ->json_is( '/branchurl'        => $library->{branchurl} )
+      ->json_is( '/branchip'        => $library->{branchip} )
+      ->json_is( '/branchnotes'      => $library->{branchnotes} )
+      ->json_is( '/opac_info'        => $library->{opac_info} )
+      ->header_is(Location => "/api/v1/libraries/$library->{branchcode}")
+      ->tx->res->json->{branchcode};
+
+    # Authorized attempt to create with null id
+    $library->{branchcode} = undef;
+    $tx = $t->ua->build_tx(
+        POST => "/api/v1/libraries" => json => $library );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(400)
+      ->json_has('/errors');
+
+    # Authorized attempt to create with existing id
+    $library->{branchcode} = $branchcode;
+    $tx = $t->ua->build_tx(
+        POST => "/api/v1/libraries" => json => $library );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(400)
+      ->json_is('/error' => 'Library already exists');
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'update() tests' => sub {
+    plan tests => 13;
+
+    $schema->storage->txn_begin;
+
+    my ( $unauthorized_borrowernumber, $unauthorized_session_id ) =
+      create_user_and_session( { authorized => 0 } );
+    my ( $authorized_borrowernumber, $authorized_session_id ) =
+      create_user_and_session( { authorized => 1 } );
+
+    my $branchcode = $builder->build( { source => 'Branch' } )->{branchcode};
+
+    # Unauthorized attempt to update
+    my $tx = $t->ua->build_tx( PUT => "/api/v1/libraries/$branchcode"
+        => json => { branchname => 'New unauthorized name change' } );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $unauthorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(403);
+
+    # Attempt partial update on a PUT
+    my $library_with_missing_field = {
+        branchaddress1 => "New library address",
+    };
+
+    $tx = $t->ua->build_tx( PUT => "/api/v1/libraries/$branchcode" =>
+                            json => $library_with_missing_field );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(400)
+      ->json_has( "/errors" =>
+          [ { message => "Missing property.", path => "/body/branchaddress2" } ]
+      );
+
+    # Full object update on PUT
+    my $library_with_updated_field = {
+        branchcode       => "LIBRARYBR2",
+        branchname       => "Library Name",
+        branchaddress1   => "Library Address1",
+        branchaddress2   => "Library Address2",
+        branchaddress3   => "Library Address3",
+        branchzip        => "Library Zipcode",
+        branchcity       => "Library City",
+        branchstate      => "Library State",
+        branchcountry    => "Library Country",
+        branchphone      => "Library Phone",
+        branchfax        => "Library Fax",
+        branchemail      => "Library Email",
+        branchreplyto    => "Library Reply-To",
+        branchreturnpath => "Library Return-Path",
+        branchurl        => "http://library.url",
+        issuing          => undef,                  # unused in Koha
+        branchip         => "127.0.0.1",
+        branchprinter    => "Library Printer",      # unused in Koha
+        branchnotes      => "Library Notes",
+        opac_info        => "<p>Library OPAC info</p>",
+    };
+
+    $tx = $t->ua->build_tx(
+        PUT => "/api/v1/libraries/$branchcode" => json => $library_with_updated_field );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(200)
+      ->json_is( '/branchname' => 'Library Name' );
+
+    # Authorized attempt to write invalid data
+    my $library_with_invalid_field = { %$library_with_updated_field };
+    $library_with_invalid_field->{'branchinvalid'} = 'Library invalid';
+
+    $tx = $t->ua->build_tx(
+        PUT => "/api/v1/libraries/$branchcode" => json => $library_with_invalid_field );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(400)
+      ->json_is(
+        "/errors" => [
+            {
+                message => "Properties not allowed: branchinvalid.",
+                path    => "/body"
+            }
+        ]
+    );
+
+    my $non_existent_code = 'nope'.int(rand(10000));
+    $tx =
+      $t->ua->build_tx( PUT => "/api/v1/libraries/$non_existent_code" => json =>
+          $library_with_updated_field );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(404);
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'delete() tests' => sub {
+    plan tests => 7;
+
+    $schema->storage->txn_begin;
+
+    my ( $unauthorized_borrowernumber, $unauthorized_session_id ) =
+      create_user_and_session( { authorized => 0 } );
+    my ( $authorized_borrowernumber, $authorized_session_id ) =
+      create_user_and_session( { authorized => 1 } );
+
+    my $branchcode = $builder->build( { source => 'Branch' } )->{branchcode};
+
+    # Unauthorized attempt to delete
+    my $tx = $t->ua->build_tx( DELETE => "/api/v1/libraries/$branchcode" );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $unauthorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(403);
+
+    $tx = $t->ua->build_tx( DELETE => "/api/v1/libraries/$branchcode" );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(204)
+      ->content_is('');
+
+    $tx = $t->ua->build_tx( DELETE => "/api/v1/libraries/$branchcode" );
+    $tx->req->cookies(
+        { name => 'CGISESSID', value => $authorized_session_id } );
+    $tx->req->env( { REMOTE_ADDR => $remote_address } );
+    $t->request_ok($tx)
+      ->status_is(404);
+
+    $schema->storage->txn_rollback;
+};
+
+sub create_user_and_session {
+
+    my $args  = shift;
+    my $flags = ( $args->{authorized} ) ? $args->{authorized} : 0;
+    my $dbh   = C4::Context->dbh;
+
+    my $user = $builder->build(
+        {
+            source => 'Borrower',
+            value  => {
+                flags => $flags
+            }
+        }
+    );
+
+    # Create a session for the authorized user
+    my $session = C4::Auth::get_session('');
+    $session->param( 'number',   $user->{borrowernumber} );
+    $session->param( 'id',       $user->{userid} );
+    $session->param( 'ip',       '127.0.0.1' );
+    $session->param( 'lasttime', time() );
+    $session->flush;
+
+    if ( $args->{authorized} ) {
+        $dbh->do( "
+            INSERT INTO user_permissions (borrowernumber,module_bit,code)
+            VALUES (?,3,'parameters_remaining_permissions')", undef,
+            $user->{borrowernumber} );
+    }
+
+    return ( $user->{borrowernumber}, $session->id );
+}
+
+1;