From 8aa9b52ac585f971e9de42a4edf245e1bf72dc84 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Mon, 27 Jun 2016 16:03:49 +0300 Subject: [PATCH] Bug 16825: Add API route for getting an item GET /api/v1/items/{item_id} Gets one Item This patch adds route to get one item from koha.items table. To test: 1. Apply patch 2. Open a browser tab on Koha staff and log in (to create CGISESSID cookie). 3. Send GET request to http://yourlibrary/api/v1/items/YYY where YYY is an existing itemnumber. 4. Make sure the returned data is correct. 5. Run unit tests in t/db_dependent/api/v1/items.t Sponsored-by: Koha-Suomi Oy Signed-off-by: Josef Moravec Signed-off-by: Johanna Raisa Signed-off-by: Michal Denar Signed-off-by: Kyle M Hall --- Koha/REST/V1/Items.pm | 216 +++++++++++++++++++++++++++ api/v1/swagger/definitions.json | 3 + api/v1/swagger/definitions/item.json | 183 +++++++++++++++++++++++ api/v1/swagger/parameters.json | 3 + api/v1/swagger/parameters/item.json | 9 ++ api/v1/swagger/paths.json | 3 + api/v1/swagger/paths/items.json | 52 +++++++ t/db_dependent/api/v1/items.t | 81 ++++++++++ 8 files changed, 550 insertions(+) create mode 100644 Koha/REST/V1/Items.pm create mode 100644 api/v1/swagger/definitions/item.json create mode 100644 api/v1/swagger/parameters/item.json create mode 100644 api/v1/swagger/paths/items.json create mode 100644 t/db_dependent/api/v1/items.t diff --git a/Koha/REST/V1/Items.pm b/Koha/REST/V1/Items.pm new file mode 100644 index 0000000000..d93af2e6bc --- /dev/null +++ b/Koha/REST/V1/Items.pm @@ -0,0 +1,216 @@ +package Koha::REST::V1::Items; + +# 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 Mojo::JSON; + +use C4::Auth qw( haspermission ); +use C4::Items qw( GetHiddenItemnumbers ); + +use Koha::Items; + +use Try::Tiny; + +sub get { + my $c = shift->openapi->valid_input or return; + + my $item; + try { + $item = Koha::Items->find($c->validation->param('item_id')); + return $c->render( status => 200, openapi => _to_api( $item->TO_JSON ) ); + } + catch { + unless ( defined $item ) { + return $c->render( status => 404, + openapi => { error => 'Item not found'} ); + } + 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."} ); + } + }; +} + +=head3 _to_api + +Helper function that maps unblessed Koha::Hold objects into REST api +attribute names. + +=cut + +sub _to_api { + my $item = shift; + + # Rename attributes + foreach my $column ( keys %{ $Koha::REST::V1::Items::to_api_mapping } ) { + my $mapped_column = $Koha::REST::V1::Items::to_api_mapping->{$column}; + if ( exists $item->{ $column } + && defined $mapped_column ) + { + # key != undef + $item->{ $mapped_column } = delete $item->{ $column }; + } + elsif ( exists $item->{ $column } + && !defined $mapped_column ) + { + # key == undef + delete $item->{ $column }; + } + } + + return $item; +} + +=head3 _to_model + +Helper function that maps REST api objects into Koha::Hold +attribute names. + +=cut + +sub _to_model { + my $item = shift; + + foreach my $attribute ( keys %{ $Koha::REST::V1::Items::to_model_mapping } ) { + my $mapped_attribute = $Koha::REST::V1::Items::to_model_mapping->{$attribute}; + if ( exists $item->{ $attribute } + && defined $mapped_attribute ) + { + # key => !undef + $item->{ $mapped_attribute } = delete $item->{ $attribute }; + } + elsif ( exists $item->{ $attribute } + && !defined $mapped_attribute ) + { + # key => undef / to be deleted + delete $item->{ $attribute }; + } + } + + return $item; +} + +=head2 Global variables + +=head3 $to_api_mapping + +=cut + +our $to_api_mapping = { + itemnumber => 'item_id', + biblionumber => 'biblio_id', + biblioitemnumber => undef, + barcode => 'external_id', + dateaccessioned => 'acquisition_date', + booksellerid => 'acquisition_source', + homebranch => 'home_library_id', + price => 'purchase_price', + replacementprice => 'replacement_price', + replacementpricedate => 'replacement_price_date', + datelastborrowed => 'last_checkout_date', + datelastseen => 'last_seen_date', + stack => undef, + notforloan => 'not_for_loan_status', + damaged => 'damaged_status', + damaged_on => 'damaged_date', + itemlost => 'lost_status', + itemlost_on => 'lost_date', + withdrawn => 'withdrawn', + withdrawn_on => 'withdrawn_date', + itemcallnumber => 'callnumber', + coded_location_qualifier => 'coded_location_qualifier', + issues => 'checkouts_count', + renewals => 'renewals_count', + reserves => 'holds_count', + restricted => 'restricted_status', + itemnotes => 'public_notes', + itemnotes_nonpublic => 'internal_notes', + holdingbranch => 'holding_library_id', + paidfor => undef, + timestamp => 'timestamp', + location => 'location', + permanent_location => 'permanent_location', + onloan => 'checked_out_date', + cn_source => 'call_number_source', + cn_sort => 'call_number_sort', + ccode => 'collection_code', + materials => 'materials_notes', + uri => 'uri', + itype => 'item_type', + more_subfields_xml => 'extended_subfields', + enumchron => 'serial_issue_number', + copynumber => 'copy_number', + stocknumber => 'inventory_number', + new_status => 'new_status' +}; + +=head3 $to_model_mapping + +=cut + +our $to_model_mapping = { + item_id => 'itemnumber', + biblio_id => 'biblionumber', + external_id => 'barcode', + acquisition_date => 'dateaccessioned', + acquisition_source => 'booksellerid', + home_library_id => 'homebranch', + purchase_price => 'price', + replacement_price => 'replacementprice', + replacement_price_date => 'replacementpricedate', + last_checkout_date => 'datelastborrowed', + last_seen_date => 'datelastseen', + not_for_loan_status => 'notforloan', + damaged_status => 'damaged', + damaged_date => 'damaged_on', + lost_status => 'itemlost', + lost_date => 'itemlost_on', + withdrawn => 'withdrawn', + withdrawn_date => 'withdrawn_on', + callnumber => 'itemcallnumber', + coded_location_qualifier => 'coded_location_qualifier', + checkouts_count => 'issues', + renewals_count => 'renewals', + holds_count => 'reserves', + restricted_status => 'restricted', + public_notes => 'itemnotes', + internal_notes => 'itemnotes_nonpublic', + holding_library_id => 'holdingbranch', + timestamp => 'timestamp', + location => 'location', + permanent_location => 'permanent_location', + checked_out_date => 'onloan', + call_number_source => 'cn_source', + call_number_sort => 'cn_sort', + collection_code => 'ccode', + materials_notes => 'materials', + uri => 'uri', + item_type => 'itype', + extended_subfields => 'more_subfields_xml', + serial_issue_number => 'enumchron', + copy_number => 'copynumber', + inventory_number => 'stocknumber', + new_status => 'new_status' +}; + +1; diff --git a/api/v1/swagger/definitions.json b/api/v1/swagger/definitions.json index e3001b5e60..4ac42cf925 100644 --- a/api/v1/swagger/definitions.json +++ b/api/v1/swagger/definitions.json @@ -23,6 +23,9 @@ "library": { "$ref": "definitions/library.json" }, + "item": { + "$ref": "definitions/item.json" + }, "patron": { "$ref": "definitions/patron.json" }, diff --git a/api/v1/swagger/definitions/item.json b/api/v1/swagger/definitions/item.json new file mode 100644 index 0000000000..2643bc113f --- /dev/null +++ b/api/v1/swagger/definitions/item.json @@ -0,0 +1,183 @@ +{ + "type": "object", + "properties": { + "item_id": { + "type": "integer", + "description": "Internal item identifier" + }, + "biblio_id": { + "type": "integer", + "description": "Internal identifier for the parent bibliographic record" + }, + "external_id": { + "type": ["string", "null"], + "description": "The item's barcode" + }, + "acquisition_date": { + "type": ["string", "null"], + "format": "date", + "description": "The date the item was acquired" + }, + "acquisition_source": { + "type": ["string", "null"], + "description": "Information about the acquisition source (it is not really a vendor id)" + }, + "home_library_id": { + "type": ["string", "null"], + "description": "Internal library id for the library the item belongs to" + }, + "purchase_price": { + "type": ["number", "null"], + "description": "Purchase price" + }, + "replacement_price": { + "type": ["number", "null"], + "description": "Cost the library charges to replace the item (e.g. if lost)" + }, + "replacement_price_date": { + "type": ["string", "null"], + "format": "date", + "description": "The date the replacement price is effective from" + }, + "last_checkout_date": { + "type": ["string", "null"], + "format": "date", + "description": "The date the item was last checked out" + }, + "last_seen_date": { + "type": ["string", "null"], + "format": "date", + "description": "The date the item barcode was last scanned" + }, + "not_for_loan_status": { + "type": "integer", + "description": "Authorized value defining why this item is not for loan" + }, + "damaged_status": { + "type": "integer", + "description": "Authorized value defining this item as damaged" + }, + "damaged_date": { + "type": ["string", "null"], + "description": "The date and time an item was last marked as damaged, NULL if not damaged" + }, + "lost_status": { + "type": "integer", + "description": "Authorized value defining this item as lost" + }, + "lost_date": { + "type": ["string", "null"], + "format": "date-time", + "description": "The date and time an item was last marked as lost, NULL if not lost" + }, + "withdrawn": { + "type": "integer", + "description": "Authorized value defining this item as withdrawn" + }, + "withdrawn_date": { + "type": ["string", "null"], + "format": "date-time", + "description": "The date and time an item was last marked as withdrawn, NULL if not withdrawn" + }, + "callnumber": { + "type": ["string", "null"], + "description": "Call number for this item" + }, + "coded_location_qualifier": { + "type": ["string", "null"], + "description": "Coded location qualifier" + }, + "checkouts_count": { + "type": ["integer", "null"], + "description": "Number of times this item has been checked out/issued" + }, + "renewals_count": { + "type": ["integer", "null"], + "description": "Number of times this item has been renewed" + }, + "holds_count": { + "type": ["integer", "null"], + "description": "Number of times this item has been placed on hold/reserved" + }, + "restricted_status": { + "type": ["integer", "null"], + "description": "Authorized value defining use restrictions for this item" + }, + "public_notes": { + "type": ["string", "null"], + "description": "Public notes on this item" + }, + "internal_notes": { + "type": ["string", "null"], + "description": "Non-public notes on this item" + }, + "holding_library_id": { + "type": ["string", "null"], + "description": "Library that is currently in possession item" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Date and time this item was last altered" + }, + "location": { + "type": ["string", "null"], + "description": "Authorized value for the shelving location for this item" + }, + "permanent_location": { + "type": ["string", "null"], + "description": "Linked to the CART and PROC temporary locations feature, stores the permanent shelving location" + }, + "checked_out_date": { + "type": ["string", "null"], + "format": "date", + "description": "Defines if item is checked out (NULL for not checked out, and checkout date for checked out)" + }, + "call_number_source": { + "type": ["string", "null"], + "description": "Classification source used on this item" + }, + "call_number_sort": { + "type": ["string", "null"], + "description": "?" + }, + "collection_code": { + "type": ["string", "null"], + "description": "Authorized value for the collection code associated with this item" + }, + "materials_notes": { + "type": ["string", "null"], + "description": "Materials specified" + }, + "uri": { + "type": ["string", "null"], + "description": "URL for the item" + }, + "item_type": { + "type": ["string", "null"], + "description": "Itemtype defining the type for this item" + }, + "extended_subfields": { + "type": ["string", "null"], + "description": "Additional 952 subfields in XML format" + }, + "serial_issue_number": { + "type": ["string", "null"], + "description": "serial enumeration/chronology for the item" + }, + "copy_number": { + "type": ["string", "null"], + "description": "Copy number" + }, + "inventory_number": { + "type": ["string", "null"], + "description": "Inventory number" + }, + "new_status": { + "type": ["string", "null"], + "description": "'new' value, whatever free-text information." + } + }, + "additionalProperties": false, + "required": ["item_id", "biblio_id", "not_for_loan_status", "damaged_status", "lost_status", "withdrawn"] +} diff --git a/api/v1/swagger/parameters.json b/api/v1/swagger/parameters.json index fce13be958..b5aa33ecbb 100644 --- a/api/v1/swagger/parameters.json +++ b/api/v1/swagger/parameters.json @@ -17,6 +17,9 @@ "library_id_pp": { "$ref": "parameters/library.json#/library_id_pp" }, + "item_id_pp": { + "$ref": "parameters/item.json#/item_id_pp" + }, "vendoridPathParam": { "$ref": "parameters/vendor.json#/vendoridPathParam" }, diff --git a/api/v1/swagger/parameters/item.json b/api/v1/swagger/parameters/item.json new file mode 100644 index 0000000000..8e54e9de37 --- /dev/null +++ b/api/v1/swagger/parameters/item.json @@ -0,0 +1,9 @@ +{ + "item_id_pp": { + "name": "item_id", + "in": "path", + "description": "Internal item identifier", + "required": true, + "type": "integer" + } +} diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json index 5d544f5c5e..946de0a6a4 100644 --- a/api/v1/swagger/paths.json +++ b/api/v1/swagger/paths.json @@ -47,6 +47,9 @@ "/checkouts/{checkout_id}/allows_renewal": { "$ref": "paths/checkouts.json#/~1checkouts~1{checkout_id}~1allows_renewal" }, + "/items/{item_id}": { + "$ref": "paths/items.json#/~1items~1{item_id}" + }, "/patrons": { "$ref": "paths/patrons.json#/~1patrons" }, diff --git a/api/v1/swagger/paths/items.json b/api/v1/swagger/paths/items.json new file mode 100644 index 0000000000..28e136d7a9 --- /dev/null +++ b/api/v1/swagger/paths/items.json @@ -0,0 +1,52 @@ +{ + "/items/{item_id}": { + "get": { + "x-mojo-to": "Items#get", + "operationId": "getItem", + "tags": ["items"], + "parameters": [{ + "$ref": "../parameters.json#/item_id_pp" + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "An item", + "schema": { + "$ref": "../definitions.json#/item" + } + }, + "400": { + "description": "Missing or wrong parameters", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "404": { + "description": "Item not found", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "503": { + "description": "Under maintenance", + "schema": { + "$ref": "../definitions.json#/error" + } + } + }, + "x-koha-authorization": { + "permissions": { + "catalogue": "1" + } + } + } + } +} diff --git a/t/db_dependent/api/v1/items.t b/t/db_dependent/api/v1/items.t new file mode 100644 index 0000000000..a3724dcdc4 --- /dev/null +++ b/t/db_dependent/api/v1/items.t @@ -0,0 +1,81 @@ +#!/usr/bin/env perl + +# Copyright 2016 Koha-Suomi +# +# 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 => 1; +use Test::Mojo; +use Test::Warn; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use C4::Auth; +use Koha::Items; +use Koha::Database; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); + +my $t = Test::Mojo->new('Koha::REST::V1'); + +subtest 'get() tests' => sub { + + plan tests => 9; + + $schema->storage->txn_begin; + + my $item = $builder->build_object( { class => 'Koha::Items' } ); + my $patron = $builder->build_object({ + class => 'Koha::Patrons', + value => { flags => 4 } + }); + + my $nonprivilegedpatron = $builder->build_object({ + class => 'Koha::Patrons', + value => { flags => 0 } + }); + + my $password = 'thePassword123'; + + $nonprivilegedpatron->set_password({ password => $password, skip_validation => 1 }); + my $userid = $nonprivilegedpatron->userid; + + $t->get_ok( "//$userid:$password@/api/v1/items/" . $item->itemnumber ) + ->status_is(403) + ->json_is( '/error' => 'Authorization failure. Missing required permission(s).' ); + + $patron->set_password({ password => $password, skip_validation => 1 }); + $userid = $patron->userid; + + $t->get_ok( "//$userid:$password@/api/v1/items/" . $item->itemnumber ) + ->status_is( 200, 'SWAGGER3.2.2' ) + ->json_is( '' => Koha::REST::V1::Items::_to_api( $item->TO_JSON ), 'SWAGGER3.3.2' ); + + my $non_existent_code = $item->itemnumber; + $item->delete; + + $t->get_ok( "//$userid:$password@/api/v1/items/" . $non_existent_code ) + ->status_is(404) + ->json_is( '/error' => 'Item not found' ); + + $schema->storage->txn_rollback; +}; -- 2.20.1 (Apple Git-117)