Bug 34587: Add integration with the counter registry API

The counter registry offers an API that provides SUSHI information for each provider. This will be incredibly useful for creating new providers as we can use the correct information from the registry for harvesting urls etc. This reduces the risk of user input errors when creating providers and gives greater reliability to the data required to successfully harvest from the SUSHI API

Signed-off-by: Jessica Zairo <jzairo@bywatersolutions.com>
Signed-off-by: Michaela Sieber <michaela.sieber@kit.edu>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
This commit is contained in:
Matt Blenkinsop 2023-08-24 14:09:57 +00:00 committed by Tomas Cohen Arazi
parent 89b6d51f07
commit b7aebb337e
No known key found for this signature in database
GPG Key ID: 0A272EA1B2F3C15F
9 changed files with 752 additions and 8 deletions

View File

@ -0,0 +1,114 @@
package Koha::REST::V1::ERM::CounterRegistry;
# Copyright 2023 PTFS Europe
# 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, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Mojo::Base 'Mojolicious::Controller';
use HTTP::Request;
use LWP::UserAgent;
use Scalar::Util qw( blessed );
use JSON qw( from_json decode_json encode_json );
use Try::Tiny qw( catch try );
use Koha::Exceptions;
=head1 API
=head2 Methods
=head3 list
=cut
sub list {
my $c = shift->openapi->valid_input or return;
my $args = $c->validation->output;
my $json = JSON->new;
my @query_params_array =
map { $_ ? $json->decode($_) : () } @{ $args->{q} };
my $search_string = $query_params_array[0]->{name};
my $url = 'https://registry.projectcounter.org/api/v1/platform/';
my $request = HTTP::Request->new(
GET => $url,
);
my $ua = LWP::UserAgent->new;
my $response = $ua->simple_request($request);
if ( $response->code >= 400 ) {
my $result = decode_json( $response->decoded_content );
my $message;
if ( ref($result) eq 'ARRAY' ) {
for my $r (@$result) {
$message .= $r->{message};
}
}
else {
$message = $result->{message} || $result->{Message} || q{};
if ( $result->{errors} ) {
for my $e ( @{ $result->{errors} } ) {
$message .= $e->{message};
}
}
}
warn sprintf "ERROR - Counter registry API %s returned %s - %s\n", $url,
$response->code, $message;
if ( $response->code == 404 ) {
Koha::Exceptions::ObjectNotFound->throw($message);
}
elsif ( $response->code == 401 ) {
Koha::Exceptions::Authorization::Unauthorized->throw($message);
}
else {
die sprintf
"ERROR requesting Counter registry API\n%s\ncode %s: %s\n", $url,
$response->code,
$message;
}
}
elsif ( $response->code == 204 ) { # No content
return;
}
my $result = decode_json( $response->decoded_content );
my @counter_5_supporting_platforms;
foreach my $platform (@$result) {
my $name_check = index( lc $platform->{name}, lc $search_string );
my @services = grep { $_->{counter_release} eq '5' }
@{ $platform->{sushi_services} };
if ( scalar(@services) > 0
&& $name_check != -1
&& scalar( @{ $platform->{reports} } ) > 0 )
{
$platform->{sushi_services} = \@services;
push @counter_5_supporting_platforms, $platform;
}
}
return $c->render(
status => 200,
openapi => \@counter_5_supporting_platforms
);
}
1;

View File

@ -0,0 +1,102 @@
package Koha::REST::V1::ERM::SushiServices;
# Copyright 2023 PTFS Europe
# 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, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use Mojo::Base 'Mojolicious::Controller';
use HTTP::Request;
use LWP::UserAgent;
use Scalar::Util qw( blessed );
use JSON qw( from_json decode_json encode_json );
use Try::Tiny qw( catch try );
use Koha::Exceptions;
=head1 API
=head2 Methods
=head3 get
=cut
sub get {
my $c = shift->openapi->valid_input or return;
my $args = $c->validation->output;
my $json = JSON->new;
my @query_params_array =
map { $_ ? $json->decode($_) : () } @{ $args->{q} };
my $service_url = $query_params_array[0]->{url};
my $request = HTTP::Request->new(
GET => $service_url
);
my $ua = LWP::UserAgent->new;
my $response = $ua->simple_request($request);
if ( $response->code >= 400 ) {
my $result = decode_json( $response->decoded_content );
my $message;
if ( ref($result) eq 'ARRAY' ) {
for my $r (@$result) {
$message .= $r->{message};
}
}
else {
$message = $result->{message} || $result->{Message} || q{};
if ( $result->{errors} ) {
for my $e ( @{ $result->{errors} } ) {
$message .= $e->{message};
}
}
}
warn sprintf "ERROR - Counter registry API %s returned %s - %s\n",
$service_url,
$response->code, $message;
if ( $response->code == 404 ) {
Koha::Exceptions::ObjectNotFound->throw($message);
}
elsif ( $response->code == 401 ) {
Koha::Exceptions::Authorization::Unauthorized->throw($message);
}
else {
die sprintf
"ERROR requesting Counter registry API\n%s\ncode %s: %s\n",
$service_url,
$response->code,
$message;
}
}
elsif ( $response->code == 204 ) { # No content
return;
}
my $result = decode_json( $response->decoded_content );
return $c->render(
status => 200,
openapi => $result
);
}
1;

View File

@ -0,0 +1,57 @@
---
type: object
properties:
id:
description: internally assigned identifier
type: string
name:
description: name of the platform
type: string
abbrev:
description: abbrev of the platform
type:
- string
- "null"
content_provider_name:
description: current content_provider_name of the platform
type:
- string
- "null"
host_types:
description: host_types for the platform
type: array
items:
type: object
address:
description: current address of the platform
type:
- string
- "null"
address_country:
description: current address country of the platform
type:
- object
- string
website:
description: current website of the platform
type:
- string
- "null"
contact:
description: current contact of the platform
type:
- object
- "null"
reports:
description: reports for the platform
type: array
items:
type: object
sushi_services:
description: sushi_services for the platform
type: array
items:
type: object
additionalProperties: false

View File

@ -0,0 +1,114 @@
---
type: object
properties:
id:
description: internally assigned identifier
type: string
data_host:
description: data_host of the sushi service
type:
- string
- "null"
counter_release:
description: counter_release of the sushi service
type:
- string
- "null"
contact:
description: contact for the sushi service
type: array
items:
type: object
url:
description: url of the sushi service
type:
- string
- "null"
customer_id_info:
description: customer_id_info of the sushi service
type:
- string
- "null"
requestor_id_required:
description: is requestor_id required
type:
- boolean
- "null"
requestor_id_info:
description: requestor_id_info of the sushi service
type:
- string
- "null"
api_key_required:
description: is api_key required
type:
- boolean
- "null"
api_key_info:
description: api_key_info of the sushi service
type:
- string
- "null"
platform_specific_info:
description: platform_specific_info of the sushi service
type:
- string
- "null"
platform_attr_required:
description: is platform_attr required
type:
- boolean
- "null"
credentials_auto_expire:
description: do the credentials expire
type:
- boolean
- "null"
credentials_auto_expire_info:
description: credentials_auto_expire_info of the sushi service
type:
- string
- "null"
request_volume_limits_applied:
description: are limits applied
type:
- boolean
- "null"
request_volume_limits_info:
description: request_volume_limits_info of the sushi service
type:
- string
- "null"
ip_address_authorization:
description: is ip address auth required
type:
- boolean
- "null"
ip_address_authorization_info:
description: ip_address_authorization_info of the sushi service
type:
- string
- "null"
customizations_in_place:
description: are any customizations in place
type:
- boolean
- "null"
customizations_info:
description: customizations_info of the sushi service
type:
- string
- "null"
notification_count:
description: notification count
type:
- integer
- "null"
notifications_url:
description: notifications_url of the sushi service
type:
- string
- "null"
additionalProperties: false

View File

@ -0,0 +1,45 @@
---
/erm/counter_registry:
get:
x-mojo-to: ERM::CounterRegistry#list
operationId: listCounterRegisteredPlatforms
tags:
- erm_counter_registry
summary: List platforms currently registered with the counter registry
produces:
- application/json
parameters:
- $ref: "../swagger.yaml#/parameters/match"
- $ref: "../swagger.yaml#/parameters/order_by"
- $ref: "../swagger.yaml#/parameters/page"
- $ref: "../swagger.yaml#/parameters/per_page"
- $ref: "../swagger.yaml#/parameters/q_param"
- $ref: "../swagger.yaml#/parameters/q_body"
responses:
200:
description: A list of counter registered platforms
schema:
items:
$ref: "../swagger.yaml#/definitions/erm_counter_registry"
type: array
400:
description: Bad request
schema:
$ref: "../swagger.yaml#/definitions/error"
403:
description: Access forbidden
schema:
$ref: "../swagger.yaml#/definitions/error"
500:
description: |-
Internal server error. Possible `error_code` attribute values:
* `internal_server_error`
schema:
$ref: "../swagger.yaml#/definitions/error"
503:
description: Under maintenance
schema:
$ref: "../swagger.yaml#/definitions/error"
x-koha-authorization:
permissions:
erm: 1

View File

@ -0,0 +1,45 @@
---
/erm/sushi_service:
get:
x-mojo-to: ERM::SushiServices#get
operationId: getSushiService
tags:
- erm_sushi_service
summary: Get the details for a sushi service from the counter registry
produces:
- application/json
parameters:
- $ref: "../swagger.yaml#/parameters/match"
- $ref: "../swagger.yaml#/parameters/order_by"
- $ref: "../swagger.yaml#/parameters/page"
- $ref: "../swagger.yaml#/parameters/per_page"
- $ref: "../swagger.yaml#/parameters/q_param"
- $ref: "../swagger.yaml#/parameters/q_body"
responses:
200:
description: A sushi service
schema:
items:
$ref: "../swagger.yaml#/definitions/erm_sushi_service"
type: object
400:
description: Bad request
schema:
$ref: "../swagger.yaml#/definitions/error"
403:
description: Access forbidden
schema:
$ref: "../swagger.yaml#/definitions/error"
500:
description: |-
Internal server error. Possible `error_code` attribute values:
* `internal_server_error`
schema:
$ref: "../swagger.yaml#/definitions/error"
503:
description: Under maintenance
schema:
$ref: "../swagger.yaml#/definitions/error"
x-koha-authorization:
permissions:
erm: 1

View File

@ -42,6 +42,8 @@ definitions:
$ref: ./definitions/erm_agreement.yaml
erm_counter_file:
$ref: ./definitions/erm_counter_file.yaml
erm_counter_registry:
$ref: ./definitions/erm_counter_registry.yaml
erm_counter_log:
$ref: ./definitions/erm_counter_log.yaml
erm_custom_report:
@ -56,6 +58,8 @@ definitions:
$ref: ./definitions/erm_eholdings_resource.yaml
erm_license:
$ref: ./definitions/erm_license.yaml
erm_sushi_service:
$ref: ./definitions/erm_sushi_service.yaml
erm_usage_data_provider:
$ref: ./definitions/erm_usage_data_provider.yaml
erm_usage_database:
@ -271,6 +275,8 @@ paths:
$ref: "./paths/erm_counter_files.yaml#/~1erm~1counter_files~1{erm_counter_files_id}"
"/erm/counter_files/{erm_counter_files_id}/file/content":
$ref: "./paths/erm_counter_files.yaml#/~1erm~1counter_files~1{erm_counter_files_id}~1file~1content"
/erm/counter_registry:
$ref: ./paths/erm_counter_registries.yaml#/~1erm~1counter_registry
/erm/default_usage_reports:
$ref: ./paths/erm_default_usage_reports.yaml#/~1erm~1default_usage_reports
"/erm/default_usage_reports/{erm_default_usage_report_id}":
@ -305,6 +311,8 @@ paths:
$ref: ./paths/erm_licenses.yaml#/~1erm~1licenses
"/erm/licenses/{license_id}":
$ref: "./paths/erm_licenses.yaml#/~1erm~1licenses~1{license_id}"
/erm/sushi_service:
$ref: ./paths/erm_sushi_services.yaml#/~1erm~1sushi_service
/erm/usage_data_providers:
$ref: ./paths/erm_usage_data_providers.yaml#/~1erm~1usage_data_providers
"/erm/usage_data_providers/{erm_usage_data_provider_id}":

View File

@ -22,12 +22,55 @@
>{{ $__("Data provider name") }}:</label
>
<input
v-if="manual_form"
id="usage_data_provider_name"
v-model="usage_data_provider.name"
:placeholder="$__('Data provider name')"
required
/>
<v-select
v-else
id="usage_data_provider_name"
v-model="selected_provider"
label="name"
:options="registry_data"
@input="getPlatformData($event)"
@update:modelValue="setPlatformData($event)"
:required="!usage_data_provider.name"
:placeholder="
$__(
'Type at least two characters to search'
)
"
>
<template v-if="searching" v-slot:no-options
>{{ $__("Searching...") }}
</template>
<template v-else v-slot:no-options
>{{
$__(
"Type at least two characters to search"
)
}}
</template>
<template #search="{ attributes, events }">
<input
:required="!usage_data_provider.name"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
<span class="required">{{ $__("Required") }}</span>
<button
v-if="!manual_form"
type="button"
style="margin-left: 1em"
@click="createManualProvider()"
>
{{ $__("Create manually") }}
</button>
</li>
<li>
<label for="usage_data_provider_description"
@ -39,6 +82,7 @@
:placeholder="$__('Description')"
rows="10"
cols="50"
:disabled="!selected_provider && !manual_form"
/>
</li>
<li>
@ -51,6 +95,7 @@
label="description"
:reduce="status => status.value"
:options="statuses"
:disabled="!selected_provider && !manual_form"
/>
</li>
<!-- <li>
@ -60,6 +105,7 @@
<input
id="usage_data_provider_method"
v-model="usage_data_provider.method"
:disabled="!selected_provider && !manual_form"
/>
</li>
<li>
@ -69,6 +115,7 @@
<input
id="usage_data_provider_aggregator"
v-model="usage_data_provider.aggregator"
:disabled="!selected_provider && !manual_form"
/>
</li> -->
<li>
@ -78,6 +125,7 @@
<input
id="usage_data_provider_service_type"
v-model="usage_data_provider.service_type"
:disabled="!selected_provider && !manual_form"
/>
</li>
<li>
@ -91,12 +139,29 @@
v-model="usage_data_provider.report_types"
label="description"
:reduce="av => av.value"
:options="av_report_types"
:options="valid_report_types"
multiple
:disabled="!selected_provider && !manual_form"
:required="
!usage_data_provider.report_types.length
"
>
<template #list-header>
<button
type="button"
@click="selectAll()"
class="list-header-btns"
>
{{ $__("Select all") }}
</button>
<button
class="list-header-btns"
type="button"
@click="unselectAll()"
>
{{ $__("Unselect all") }}
</button>
</template>
<template #search="{ attributes, events }">
<input
:required="
@ -114,8 +179,8 @@
</ol>
</fieldset>
<legend>{{ $__("Sushi credentials") }}</legend>
<fieldset class="rows">
<ol>
<fieldset class="rows credentials">
<ol class="credentials_form">
<li>
<label
class="required"
@ -124,8 +189,10 @@
</label>
<input
id="usage_data_provider_service_url"
style="min-width: 60%"
v-model="usage_data_provider.service_url"
required
:disabled="!selected_provider && !manual_form"
/>
<span class="required">{{ $__("Required") }}</span>
</li>
@ -137,8 +204,10 @@
</label>
<input
id="usage_data_provider_report_release"
style="min-width: 60%"
v-model="usage_data_provider.report_release"
required
:disabled="!selected_provider && !manual_form"
/>
<span class="required">{{ $__("Required") }}</span>
</li>
@ -150,32 +219,60 @@
</label>
<input
id="usage_data_provider_customer_id"
style="min-width: 60%"
v-model="usage_data_provider.customer_id"
required
:disabled="!selected_provider && !manual_form"
/>
<span class="required">{{ $__("Required") }}</span>
</li>
<li>
<label
class="required"
:class="
required_fields.includes('Requestor')
? 'required'
: ''
"
for="usage_data_provider_requestor_id"
>{{ $__("Requestor Id") }}:
</label>
<input
id="usage_data_provider_requestor_id"
style="min-width: 60%"
v-model="usage_data_provider.requestor_id"
required
:required="
required_fields.includes('Requestor')
"
:disabled="!selected_provider && !manual_form"
/>
<span class="required">{{ $__("Required") }}</span>
<span
class="required"
v-if="required_fields.includes('Requestor')"
>{{ $__("Required") }}
</span>
</li>
<li>
<label for="usage_data_provider_api_key"
<label
for="usage_data_provider_api_key"
:class="
required_fields.includes('API')
? 'required'
: ''
"
>{{ $__("API key") }}:
</label>
<input
id="usage_data_provider_api_key"
style="min-width: 60%"
v-model="usage_data_provider.api_key"
:disabled="!selected_provider && !manual_form"
:required="required_fields.includes('API')"
/>
<span
class="required"
v-if="required_fields.includes('API')"
>{{ $__("Required") }}
</span>
</li>
<li>
<label for="usage_data_provider_requestor_name"
@ -183,7 +280,9 @@
</label>
<input
id="usage_data_provider_requestor_name"
style="min-width: 60%"
v-model="usage_data_provider.requestor_name"
:disabled="!selected_provider && !manual_form"
/>
</li>
<li>
@ -192,10 +291,52 @@
</label>
<input
id="usage_data_provider_requestor_email"
style="min-width: 60%"
v-model="usage_data_provider.requestor_email"
:disabled="!selected_provider && !manual_form"
/>
</li>
</ol>
<ol class="credentials_form" v-if="sushi_service">
<h3>{{ $__("Credentials information") }}</h3>
<li>
<label for="customer_id_info"
>{{ $__("Customer id info") }}:
</label>
<span id="customer_id_info">{{
sushi_service.customer_id_info
? $__(sushi_service.customer_id_info)
: "No information provided"
}}</span>
</li>
<li>
<label for="requestor_id_info"
>{{ $__("Requestor id info") }}:
</label>
<span id="requestor_id_info">{{
sushi_service.requestor_id_info
? $__(sushi_service.requestor_id_info)
: "No information provided"
}}</span>
</li>
<li>
<label for="api_key_info"
>{{ $__("API key info") }}:
</label>
<span id="api_key_info">{{
sushi_service.api_key_info
? $__(sushi_service.api_key_info)
: "No information provided"
}}</span>
</li>
<span
>{{ $__("Please refer to the ") }}
<a href="https://registry.projectcounter.org/">{{
$__("counter registry")
}}</a>
{{ $__("for more information.") }}</span
>
</ol>
</fieldset>
<fieldset class="action">
<ButtonSubmit />
@ -221,7 +362,7 @@
<script>
import ButtonSubmit from "../ButtonSubmit.vue"
import { setMessage } from "../../messages"
import { setMessage, setWarning } from "../../messages"
import { APIClient } from "../../fetch/api-client.js"
import { inject } from "vue"
import { storeToRefs } from "pinia"
@ -262,6 +403,13 @@ export default {
{ description: "Inactive", value: 0 },
],
fp_config: flatpickr_defaults,
registry_data: [],
valid_report_types: [...this.av_report_types],
selected_provider: null,
manual_form: false,
required_fields: [],
sushi_service: null,
searching: false,
}
},
beforeRouteEnter(to, from, next) {
@ -286,11 +434,91 @@ export default {
this.usage_data_provider = usage_data_provider
this.usage_data_provider.report_types =
this.formatReportTypes(usage_data_provider.report_types)
this.getCounterRegistry(usage_data_provider.name, "edit")
this.initialized = true
},
error => {}
)
},
async getCounterRegistry(query, caller) {
const client = APIClient.erm
client.counter_registry.getAll({ name: query }).then(
registry_data => {
this.registry_data = registry_data
if (caller === "edit") {
this.selected_provider = registry_data.find(
platform =>
platform.name === this.usage_data_provider.name
)
this.valid_report_types =
this.valid_report_types.filter(report =>
this.selected_provider.reports.some(
r => r.report_id === report.value
)
)
}
this.searching = false
},
error => {}
)
},
async getSushiService(url) {
const client = APIClient.erm
client.sushi_service.getAll({ url }).then(
sushi_service => {
const { url, api_key_required, requestor_id_required } =
sushi_service
this.usage_data_provider.service_url = url
this.required_fields = []
api_key_required && this.required_fields.push("API")
requestor_id_required &&
this.required_fields.push("Requestor")
this.sushi_service = sushi_service
},
error => {}
)
},
getPlatformData(e) {
if (e.target.value.length > 1) {
this.searching = true
this.getCounterRegistry(e.target.value, "search")
}
},
async setPlatformData(e) {
const { sushi_services, reports, name } = e
this.valid_report_types = this.valid_report_types.filter(report =>
reports.some(r => r.report_id === report.value)
)
if (this.valid_report_types.length === 0) {
setWarning(
this.$__(
"This provider does not currently support any counter 5 reports via SUSHI."
)
)
}
this.selected_provider = e
this.usage_data_provider.name = name
await this.getSushiService(sushi_services[0].url)
this.usage_data_provider.report_release =
sushi_services[0].counter_release
},
createManualProvider() {
this.manual_form = true
this.selected_provider = null
this.sushi_service = null
this.required_fields = ["API", "Requestor"]
},
selectAll() {
this.usage_data_provider.report_types = this.valid_report_types.map(
report => {
return report.value
}
)
},
unselectAll() {
this.usage_data_provider.report_types = []
},
formatReportTypes(reportTypes) {
const dataType =
typeof reportTypes === "string" ? "string" : "array"
@ -366,3 +594,15 @@ export default {
name: "UsageStatisticsDataProvidersFormAdd",
}
</script>
<style scoped>
.credentials {
display: flex;
}
.credentials_form {
width: 50%;
}
.list-header-btns {
margin: 0.5em 0 0.5em 0.5em;
}
</style>

View File

@ -425,6 +425,25 @@ export class ERMAPIClient extends HttpClient {
}),
};
}
get counter_registry() {
return {
getAll: query =>
this.getAll({
endpoint: "counter_registry",
query,
}),
};
}
get sushi_service() {
return {
getAll: query =>
this.getAll({
endpoint: "sushi_service",
query,
}),
};
}
}
export default ERMAPIClient;