
[GIT] Evergreen ILS branch main updated. b674009b1b1df25d6ba61e74a81153fd384d5415
by Git User 22 Mar '25
by Git User 22 Mar '25
22 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via b674009b1b1df25d6ba61e74a81153fd384d5415 (commit)
from 491816b999f6732db8fd11f235d97bca31ffe426 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit b674009b1b1df25d6ba61e74a81153fd384d5415
Author: Chris Sharp <csharp(a)georgialibraries.org>
Date: Wed Mar 19 14:14:01 2025 -0400
LP#2103630: QueryParser.pm should check if $org is defined
QueryParser.pm was trying to check visibility of orgs that
don't yet exist in the cached version of the org tree object.
Check that the $org object actually exists before trying to
run methods on it.
Release-note: Fix a bug that caused empty search results in
the OPAC after changes were made to the organizational unit
tree and the autogen script had not yet been run.
Signed-off-by: Chris Sharp <csharp(a)georgialibraries.org>
Signed-off-by: Jane Sandberg <sandbergja(a)gmail.com>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
index f40c6d1530..6a77108378 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/QueryParser.pm
@@ -1215,15 +1215,19 @@ SQL
sub is_org_visible {
my $org = shift;
- return 0 if (!$U->is_true($org->opac_visible));
+ if ( defined($org) ) {
+ return 0 if (!$U->is_true($org->opac_visible));
- my $non_inherited_vis_gf = shift || $U->get_global_flag('opac.org_unit.non_inherited_visibility');
- return 1 if ($U->is_true($non_inherited_vis_gf->enabled));
+ my $non_inherited_vis_gf = shift || $U->get_global_flag('opac.org_unit.non_inherited_visibility');
+ return 1 if ($U->is_true($non_inherited_vis_gf->enabled));
- while ($org = $org->parent_ou) {
- return 0 if (!$U->is_true($org->opac_visible));
+ while ($org = $org->parent_ou) {
+ return 0 if (!$U->is_true($org->opac_visible));
+ }
+ return 1;
+ } else {
+ return 0;
}
- return 1;
}
sub flesh_parents {
-----------------------------------------------------------------------
Summary of changes:
.../OpenILS/Application/Storage/Driver/Pg/QueryParser.pm | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch main updated. 491816b999f6732db8fd11f235d97bca31ffe426
by Git User 22 Mar '25
by Git User 22 Mar '25
22 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via 491816b999f6732db8fd11f235d97bca31ffe426 (commit)
via 5fdc3d0b3c466c71a8956aab613f84eb50d4bbfe (commit)
via 6ef607d3c225c0dc238240828d89eb5346ea2a08 (commit)
via 649f38ac6900fdb6936b0356db846bd72d0a9246 (commit)
via f2bfeb5778692b1a563d6c777c83049bb5bd5cba (commit)
via 862ad66d1d6e62e649abb3fcbf67d87def0b679a (commit)
via c8e65252507ff94dff5e1e19835c356f92321c2b (commit)
via 2a308edb8312a2b5ea422892de5fd7e8e0a2771a (commit)
via 5c79bf7d69d99f0e72882d46b112d51d3162d539 (commit)
via 461e5cda890ebaab4d013f8e0b3a14e50e09b6e7 (commit)
from b10b089b32f7f920764177942965bd10af9b9c27 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit 491816b999f6732db8fd11f235d97bca31ffe426
Author: Jeff Godin <jgodin(a)tadl.org>
Date: Fri Mar 21 23:57:03 2025 -0400
LP#2067414: Adjust docs wording based on feedback
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Ruth Frasur Davis <rdavis(a)evergreencdi.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/docs/modules/integrations/pages/restful_api.adoc b/docs/modules/integrations/pages/restful_api.adoc
index 7626f5652a..000da07a0c 100644
--- a/docs/modules/integrations/pages/restful_api.adoc
+++ b/docs/modules/integrations/pages/restful_api.adoc
@@ -5,7 +5,7 @@
The below documentation describes the new RESTful API suite for Evergreen.
-For now, this is an experimental feature, intended as a starting point to encourage further testing. A more fully developed version of the API will be included in a future release.
+This is an initial release of the foundation and infrastructure to support early adoption, providing a starting point to encourage further testing and integration. Additional API functionality will be included in a future release.
== Terminology ==
commit 5fdc3d0b3c466c71a8956aab613f84eb50d4bbfe
Author: Jeff Godin <jgodin(a)tadl.org>
Date: Fri Mar 21 23:56:36 2025 -0400
LP#2067414: Stamp upgrade script
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 5a26813b54..13d695aadf 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1467', :eg_version); -- sandbergja/blake/sleary
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1468', :eg_version); -- miker/rdavis/jeffdavis/jeff
CREATE TABLE config.bib_source (
id SERIAL PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openapi.sql b/Open-ILS/src/sql/Pg/upgrade/1468.schema.openapi.sql
similarity index 99%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openapi.sql
rename to Open-ILS/src/sql/Pg/upgrade/1468.schema.openapi.sql
index 07afff5149..6b7e60ee76 100755
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openapi.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1468.schema.openapi.sql
@@ -1,5 +1,7 @@
BEGIN;
+SELECT evergreen.upgrade_deps_block_check('1468', :eg_version);
+
-- Necessary pre-seed data
commit 6ef607d3c225c0dc238240828d89eb5346ea2a08
Author: Jeff Davis <jdavis(a)sitka.bclibraries.ca>
Date: Wed Mar 19 15:49:12 2025 -0700
LP#2067414: Describe REST API as "experimental" in documentation
Signed-off-by: Jeff Davis <jdavis(a)sitka.bclibraries.ca>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/docs/modules/integrations/pages/restful_api.adoc b/docs/modules/integrations/pages/restful_api.adoc
index b5a6ea9b9f..7626f5652a 100644
--- a/docs/modules/integrations/pages/restful_api.adoc
+++ b/docs/modules/integrations/pages/restful_api.adoc
@@ -3,7 +3,9 @@
== Introduction ==
-The below documentation describes the new RESTful API suite for Evergreen, rewritten in late 2024.
+The below documentation describes the new RESTful API suite for Evergreen.
+
+For now, this is an experimental feature, intended as a starting point to encourage further testing. A more fully developed version of the API will be included in a future release.
== Terminology ==
commit 649f38ac6900fdb6936b0356db846bd72d0a9246
Author: Galen Charlton <gmc(a)equinoxOLI.org>
Date: Mon Mar 17 17:51:20 2025 -0400
LP#2067414: adjust and document API version
This patch documents expectations around the versioning of the
OpenAPI endpoints.
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Davis <jdavis(a)sitka.bclibraries.ca>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/Open-ILS/src/support-scripts/openapi_server b/Open-ILS/src/support-scripts/openapi_server
index a7e2d0567e..a8fbb625aa 100755
--- a/Open-ILS/src/support-scripts/openapi_server
+++ b/Open-ILS/src/support-scripts/openapi_server
@@ -85,7 +85,7 @@ my $config = {
}
},
servers => [
- { url => "/openapi3/v1" }
+ { url => "/openapi3/v0" }
],
tags => [],
components => {
diff --git a/docs/modules/integrations/pages/restful_api.adoc b/docs/modules/integrations/pages/restful_api.adoc
index 5e5308a57a..b5a6ea9b9f 100644
--- a/docs/modules/integrations/pages/restful_api.adoc
+++ b/docs/modules/integrations/pages/restful_api.adoc
@@ -639,7 +639,7 @@ The Evergreen OpenAPI endpoints are not meant for use by humans directly in a br
The way OpenAPI clients and servers work together in Evergreen, through this development specifically, is as follows:
-. The server software produces an API specification document in JSON or YAML. This is available at https://<hostname>/openapi3/v1 on any fully installed instance of this development.
+. The server software produces an API specification document in JSON or YAML. This is available at https://<hostname>/openapi3/v0 on any fully installed instance of this development.
. The client software consumes that specification document in order to understand what API calls are available, how it should send parameter data to the API calls, and what the format of the output of an API call will look like.
There are standard tools to automate much of the client-side work, but the result of creating a functioning OpenAPI client will be an application making HTTP requests.
@@ -674,6 +674,10 @@ NOTE: The JSON Schema is very large, and tends to cause the both locally hosted
The web-based Swagger-UI editor and visualization tools are not robust or sophisticated enough to handle such a large and complex component schema. This is a limitation of the basic demo Swagger tools. True client applications do not try to render the full JSON Schema, are written to be robust and correct, and are not expected to have these sorts of issues if they are designed well.
+=== API Versioning ===
+
+Evergreen's OpenAPI support establishes a framework for clients to access and manipulate Evergreen data as well as a set of predefined endpoints. Consequently, additional endpoints are expected to be added over time. Like any API, the definitions of endpoints are subject to change, especially as the number of clients using the endpoints grows. An API version stamp in the path component of the base URL will be used to signal whether breaking changes have been introduced in the API. The initial release of the API sets the version as `v0` (i.e., `https://<hostname>/openapi3/v0`). This will get incremented whenever backwards-incompatible changes get made to existing endpoint or if changes to core Fieldmapper IDL classes could break clients.
+
[[adding_endpoints_example]]
== Example: Adding endpoints to Evergreen's OpenAPI server ==
commit f2bfeb5778692b1a563d6c777c83049bb5bd5cba
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Tue Mar 11 17:37:58 2025 -0400
Docs: RESTful API documentation
Co-authored-by: Andrea Buntz Neiman <abneiman(a)equinoxinitiative.org>
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Andrea Buntz Neiman <abneiman(a)equinoxinitiative.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/docs/modules/integrations/assets/images/restful_api/api_explorer.png b/docs/modules/integrations/assets/images/restful_api/api_explorer.png
new file mode 100644
index 0000000000..3fca2bcb2b
Binary files /dev/null and b/docs/modules/integrations/assets/images/restful_api/api_explorer.png differ
diff --git a/docs/modules/integrations/assets/images/restful_api/block_expanded.png b/docs/modules/integrations/assets/images/restful_api/block_expanded.png
new file mode 100644
index 0000000000..1f00178298
Binary files /dev/null and b/docs/modules/integrations/assets/images/restful_api/block_expanded.png differ
diff --git a/docs/modules/integrations/assets/images/restful_api/create_endpoint.png b/docs/modules/integrations/assets/images/restful_api/create_endpoint.png
new file mode 100644
index 0000000000..ea8322ad15
Binary files /dev/null and b/docs/modules/integrations/assets/images/restful_api/create_endpoint.png differ
diff --git a/docs/modules/integrations/assets/images/restful_api/expected_parameters.png b/docs/modules/integrations/assets/images/restful_api/expected_parameters.png
new file mode 100644
index 0000000000..3c0b77cce3
Binary files /dev/null and b/docs/modules/integrations/assets/images/restful_api/expected_parameters.png differ
diff --git a/docs/modules/integrations/assets/images/restful_api/method_details.png b/docs/modules/integrations/assets/images/restful_api/method_details.png
new file mode 100644
index 0000000000..a9f4fe5667
Binary files /dev/null and b/docs/modules/integrations/assets/images/restful_api/method_details.png differ
diff --git a/docs/modules/integrations/nav.adoc b/docs/modules/integrations/nav.adoc
index 9b04db7e34..82ef54bdbe 100644
--- a/docs/modules/integrations/nav.adoc
+++ b/docs/modules/integrations/nav.adoc
@@ -1,5 +1,6 @@
* xref:integrations:introduction.adoc[Integrating Evergreen with Other Tools]
** xref:integrations:ezproxy.adoc[EZProxy]
** xref:integrations:patron-api.adoc[PatronAPI authentication]
+** xref:integrations:restful_api.adoc[RESTful API]
** xref:integrations:web_services.adoc[Web Services]
diff --git a/docs/modules/integrations/pages/restful_api.adoc b/docs/modules/integrations/pages/restful_api.adoc
new file mode 100644
index 0000000000..5e5308a57a
--- /dev/null
+++ b/docs/modules/integrations/pages/restful_api.adoc
@@ -0,0 +1,877 @@
+= RESTful API in Evergreen =
+:toc:
+
+== Introduction ==
+
+The below documentation describes the new RESTful API suite for Evergreen, rewritten in late 2024.
+
+== Terminology ==
+
+**OpenAPI**
+
+An industry standard https://swagger.io/specification/[API specification] managed by the Linux Foundation.
+
+**JSON Schema**
+
+An industry standard https://json-schema.org/specification[specification] for describing data structures shared between systems using JSON documents.
+
+**Operation ID**
+
+The unique identifier of an OpenAPI method served via a specific HTTP method and path.
+
+**Endpoint**
+
+The term used within the Evergreen OpenAPI Server to describe the functional response handler assigned to an OpenAPI Operation ID.
+
+**Endpoint Parameter**
+
+The description and specification of an OpenAPI input parameter that can be mapped to an internal method endpoint parameter.
+
+**Endpoint Response**
+
+The description and specification of a response structure that is returned from an endpoint.
+
+**Endpoint Set**
+
+A group of endpoints that can be configured with access using a single permission set. Analogous to the tag concept in OpenAPI.
+
+**Permission Set**
+
+A group of permissions that, when all are applied to an Evergreen user that has been configured as an API Integrator, allow access to an endpoint or endpoint set.
+
+**Rate Limit Definition**
+
+A combination of time interval and access attempt count that define the maximum average rate of access attempts allowed by a specific API Integrator, or from an IP address or address range, or globally for the Evergreen OpenAPI Server instance.
+
+**API Integrator**
+
+An Evergreen user that has been configured specifically for use in accessing the OpenAPI endpoints provided by the Evergreen OpenAPI Server.
+
+== Architecture ==
+
+=== Integrators ===
+
+Access is permitted to the OpenAPI server for those Evergreen user accounts that have been promoted to an Integrator account, and have had an additional password with the new api password type associated with them. These accounts do not need to have a known main password, nor do they need to have login type permissions other than API_LOGIN. Such accounts are effectively API-only accounts, and while they may be given some set of staff-like permissions in order to access necessary patron and transaction data, they do not need to have staff client or other elevated access to Evergreen.
+
+Today, promotion of an account to be an Integrator is managed using the `api_ctl` tool. The `api_ctl` command path to add a user as an Integrator is *integrator -> add -> USERNAME* and the command path to set the password for Integrator API login is *integrator -> load -> USERNAME -> password*. This can be accomplished in a single command directly from the command line:
+
+`$ api_ctl --command integrator add USERNAME password`
+
+which will then prompt for the API password to assign to the newly promoted user. A newly added Integrator is enabled by default. However, an Integrator promotion can be disable, re-enabled, or removed similarly:
+
+`$ api_ctl --command integrator disable USERNAME`
+
+`$ api_ctl --command integrator enable USERNAME`
+
+`$ api_ctl --command integrator remove USERNAME`
+
+More details on the integrator control functionality of the `api_ctl` tool can be found below.
+
+While it is possible to use any valid authentication token to request methods through the OpenAPI server, including those created through OPAC and Staff Client login, logging in through the OpenAPI server itself using the `/self/auth` path is additionally protected and the user must meet the following minimum requirements:
+
+* The user must have the API_LOGIN permission
+* The user must be promoted as an Integrator
+* That Integrator promotion must be enabled
+* The Integrator must have an 'api'-type password
+
+These restrictions are imposed by the open-ils.auth OpenSRF application, and are intended to prevent accidentally allowing OpenAPI access. A caveat to the foregoing is noted below, to facilitate convenient patron access to the OpenAPI server.
+
+=== Rate Limit Definitions ===
+
+Rate of access to each endpoint can be restricted by the use of Rate limit definitions. These simple rules consist of a limit interval and a limit count, where there must be no more than limit count requests over the preceding limit interval time.
+
+Each endpoint request is evaluated in the context of the user making the request and their IP address, the Rate limit definition attached to the endpoint or the default fallback limit, any Rate limit definitions attached to the endpoint set into which the endpoint is placed, and any Rate limit definitions that combine the user and IP address with the endpoint or relevant endpoint set.
+
+If any user or IP address related Rate limit definitions are configured for the request, the most restrictive of those is applied before allowing access. If no user or IP address related Rate limit definitions are configured, the most restrictive of the general endpoint and endpoint Set limits is used.
+
+=== Permission Sets ===
+
+Access to each API method is restricted based on the Evergreen permissions assigned to the user that has been promoted to Integrator. For flexibility and ease of configuration, these access permissions can be grouped into permission sets. These permission sets are then assigned to endpoints and endpoint set, described below, and the
+Integrator must have all of the permissions in at least one of the permission sets in effect for an endpoint in order to successfully call the API method.
+
+For example, there are two stock permission sets associated with the _self_ endpoint set, which includes the primary authentication API method located at the `/self/auth` path and methods that allow user self-service functionality.
+
+One set, the _Self - standard permissions_ permission set, includes just the OPAC_LOGIN permission. This allows
+normal patron-type users that have been promoted to Integrator and have been given an API password to log into the OpenAPI server using Basic authentication. This user will log in with that separate password to access the self-service methods, or use the URL parameter login to supply a login type of "opac" with their main username and password credentials. Another set, the _Self - API only_ permission set, contains the new API_LOGIN and REST.api permissions and allows login and self-service requests only for Integrator users that have both of those new permission. They do not need to have the OPAC_LOGIN permission.
+
+As a more complicated example, there exists a stock API method at the path `/patron/:userid` which returns detailed user information on the user with an ID specified at the `:userid` placeholder. This method is in the patron-oriented _patron_ endpoint set which is protected by default using two permission sets, one containing the API_LOGIN, REST.api, and REST.api.patrons permissions, and the other containing the STAFF_LOGIN and VIEW_USER permissions. Additionally, an administrator may associate the endpoint with an endpoint-specific permission set, named _Custom patron retrieval_ for this example, containing REST.api and REST.api.patrons.detail.read permissions.
+
+Thus, there are three permission sets in effect for the `/patron/:userid` endpoint:
+
+* _Patrons - standard permissions_: STAFF_LOGIN, VIEW_USER
+* _Patrons - API only_: API_LOGIN, REST.api, REST.api.patrons
+* _Custom patron retrieval_: API_LOGIN, REST.api, REST.api.patrons.detail.read
+
+The first permission set would be congruent with a normal Evergreen staff account accessing the OpenAPI server with a Staff Client authentication token, the second with an Integrator account that has been granted general patron-related API access, and the third with an Integrator account that has been granted specific access to that one API endpoint by having the REST.api.patrons.detail.read permission, but does not have general patron-related REST.api.patrons permission.
+
+NOTE: For convenience, it is possible to pass a login type parameter either using the URL parameter style login, or by adding the desired login type to the end of the Basic authentication string, separated from the username and password by a colon, before base64 encoding the data. This allows non-Integrator accounts to log into the OpenAPI server if they could otherwise log into Evergreen using a login type other than "api".
+
+If an authenticated account accessing this API has all of the permissions in any of those sets, then the API's logic is allowed to execute. Users are assigned permissions in the normal Evergreen way, by having the permission added to their account directly or by being made a member of a primary or secondary permission group which has the permission assigned to it.
+
+Once the permission to execute the API's logic has been established, the requesting account's normal permissions must still be applied to any underlying action. In this example, if the backend method that retrieves patron record detail requires the VIEW_USER permission be granted to the requestor at the home library of the patron record, that must still hold in order for the API to return the requested data.
+
+==== Default Permission Groups ====
+
+The new, additional permission groups meant to support API access for Integrator accounts (that is, via the "api-only" permission sets) look like this:
+
+[width="100%",cols="10%,46%,44%",options="header",]
+|===
+|*id* |*perm_group* |*permissions*
+|25 |API Integrator |API_LOGIN ; REST.api
+|26 |Patron API |REST.api.patrons
+|27 |Organizational Unit API |REST.api.orgs
+|28 |Bib Record API |REST.api.bibs
+|29 |Item Record API |REST.api.items
+|30 |Holds API |REST.api.holds
+|31 |Debt Collection API |REST.api.collections
+|32 |Course Reserves API |REST.api.courses
+|===
+
+=== Endpoints and Endpoint Sets ===
+
+Evergreen's OpenAPI Server exposes specific business logic functions and data access pathways as API endpoints.
+
+Each of these endpoints present a single backend function, usually a way to request particular data or initiate a specific action, by translating OpenAPI paths, HTTP methods, and HTTP headers and parameters to handler functions and their necessary parameters.
+
+These handler functions may be either OpenSRF service and method pairs, or when some amount of pre- or post-processing of an OpenSRF method call is required, a Perl module and optional sub name that supplies any necessary additional logic.
+
+Once the handler has run, the requested output is translated to an OpenAPI response, normally as JSON or XML formatted data. The requests may make use of parameter data passed via HTTP path components or query parameters, HTTP cookies or headers, or the HTTP request body.
+
+Endpoints can be thought of as OpenAPI wrappers to existing Evergreen functionality implemented in OpenSRF.
+
+Structurally, each endpoint consists of the following information:
+
+* _Operation ID_ - The unique identifier for the endpoint.
+* _Path_ - The HTTP path, which may include contextual information such as placeholders for identifiers or behavioral options, that makes up part of the URL used to address the service through a standard web server.
+* _HTTP Method_ - The HTTP verb used to signal the type of action being requested; one of `get`, `put`, `post`, `delete`, and `patch`.
+* _Security method_ - How the OpenAPI server should expect to receive authentication and authorization credentials.
+** The specialized authentication endpoint can use Basic and Parameter Security.
+** All other endpoints default to Bearer Authentication, though all of Bearer, Cookie, and Parameter are generally usable for most endpoints.
+* _Summary_ - A textual description of the purpose of the endpoint.
+* _Method Source_ - Either an OpenSRF application name, or Perl package name.
+* _Method Name_ - Either an OpenSRF method from the OpenSRF application, or Perl subroutine from the Perl package.
+* _Method Parameters_ - A space-separated mapping from the available named OpenAPI parameters and environmental data to the positional parameters of the backend OpenSRF method or Perl subroutine.
+* _Active flag_ - Controls general access to the configured endpoint; when active, the endpoint is exposed through OpenAPI.
+* _Default Rate Limit Definition_ - Rate limit definition to apply to the endpoint, unless a context-specific user rate limit or IP address rate limit is configured.
+** The authentication endpoints use the "100 requests per second" rate limit by default.
+** All other endpoints default to "1 request per second per user" if the user is known to the server at request time, or to "1 request per second per IP address" if the IP address is available to the server at request time, or "1 request per second globally" if neither are available at the time of the request.
+
+These endpoints can then be gathered together into logical groups, called endpoint sets, to simplify access configuration and documentation. Endpoint sets can supply permission sets and rate limit definitions for endpoints that they contain. For permission sets, if an endpoint is in more than one endpoint set, all mapped permission sets are applied to the endpoint and successful authorization against any of the permission sets will allow access.
+
+Rate limit definition selection is more complicated, but fully predictable.
+
+First, all definitions related to the relevant endpoint sets (and the specific endpoint) that are attached with the context of the user or their IP address, if any, are compared and the strictest rate limit definition is applied. If there are no user or IP address contextual rate limit definitions in place, the strictest rate limit definition attached to the endpoint or any of its endpoint set is applied to the request, first against the user for the endpoint if the user is known, then against the IP address for the endpoint if the IP address is known, or finally, globally against the endpoint.
+
+==== Endpoint Parameters ====
+
+Because OpenSRF and OpenAPI have different calling conventions as well as different response format and structure, it is necessary to map between these two programmatic interfaces. Where possible, the OpenAPI server translates between simple JSON Objects and Evergreen fieldmapper objects automatically, validating both input and output for validity and correctness.
+
+Parameter mapping is configured by describing the expected incoming parameter layout in terms of input type, location, and name. Parameters to OpenAPI endpoints can be passed as part of the URL path, as URL query parameters, as HTTP headers, or as cookies.
+
+The datatype of each parameter is specified as either a JSON schema datatype, with the ability to specify an expected element type for arrays or expect semantic format for scalar types, or as an Evergreen fieldmapper class hint.
+
+**Supported JSON schema datatypes**
+
+* object
+* array
+* boolean
+* string
+* integer
+* number
+
+**Supported optional JSON schema scalar semantic format descriptors**
+
+* String formats
+** date-time
+** date
+** time
+** interval
+** email
+** uri
+** identifier
+** password
+* Number formats
+** money
+** float
+* Integer format
+** int64
+
+Additionally, all Fieldmapper class hints can be used as parameter types, and the OpenAPI server will validate incoming JSON Objects to confirm that they contain all required properties, and do not contain any that are not defined by the Fieldmapper IDL.
+
+In addition to the location, type, and format configuration, OpenAPI parameters can be marked as required, which will be validated by the OpenAPI server, and can define a default value to be passed when non-required parameters are not supplied by the user.
+
+Once parameters have been described, they are available for mapping into OpenSRF positional parameters. This OpenSRF calling convention mapping is configured at the endpoint level. The named OpenAPI parameters are accessible in two ways:
+
+* Via the `param` namespace, which supplies the last instance of the parameter processed when the parameter is expected to carry one value and a scalar should be passed to the backend method. For example `param.userid` might be the userid value extracted from a URL path placeholder.
+* Via the `every_param` namespace, if a parameter is expected to be repeatable and all values should be passed to the backend method. For example `every_param.org` might be all values from a repeatable `org` URL query parameter.
+
+In addition to the named parameters provided directly to the OpenAPI server as described above and accessed through the `param` and `every_param` namespaces, several built-in reserved parameter names are available to all non-authentication methods:
+
+* `eg_auth_token` - The authentication token provided with the HTTP request, usually via the authentication header as a Bearer token.
+* `eg_user_id` - The user id of the authenticated user.
+* `eg_user_obj` - The full user object of the authenticated user.
+* `req.json` - The HTTP request body, parsed as JSON and provided as a Perl data object to the backend method.
+* `req.text` - The HTTP request body, as plain text.
+
+Finally, quoted string literals can be used as mapped positional parameters in cases where the backend method requires some value, but there is no need to make the parameter available from end-user input.
+
+Configured OpenAPI parameters are mapped to backend parameters in a space separated list associated with the endpoint, which also defines the type, manner, and location of the backend method. For example, the "/self/me" path that retrieves the user record for the logged in user, and the "/patron/\{userid}" path that attempts to retrieve an arbitrary user record, subject to all API access and Evergreen permission restrictions, use the same backend method, and substantively differ only in their parameter mapping:
+
+* /self/me: `eg_auth_token eg_user_id`
+* /patron/\{userid}: `eg_auth_token param.userid`
+
+==== Endpoint Responses ====
+
+Endpoint responses are described in a very similar way to parameters, with optional output validation and optional Fieldmapper class hint or JSON Schema type information. However, they differ in that responses cannot have a default value, and are also described by their HTTP response status code (200, 401, 404, 500, etc) and message, as well as their expected MIME content type. The content type will generally be either `application/json or text/plain`, though, for example, the provided bibliographic record retrieval endpoint also offers both `application/xml` and `application/octet-stream` to support alternate negotiated formats.
+
+In order to support varying response formats, the OpenAPI server investigates the HTTP Accept header for each request and determines the best response type-match to the requested content type; this is called the resolved content format. The resolution of the best content type to respond with based on the Accept header is performed per the HTTP specification, taking into account the "q" weight parameter as well as the completeness (or, use of wildcards) for the types. This is made available to the parameter mapping function with the special reserved name `eg_req_resolved_content_format`, and when using a Perl module and method rather than an OpenSRF service and method, to the handler method via the `stash()` function of the invocant, which is passed as its first method parameter.
+
+The default resolved content format is assumed to be `json` to support JSON Schema and Fieldmapper object output which will be the most common result. The high-level types that can be detected and used are:
+
+* json
+* text
+* html
+* xml
+* binary
+
+For those responses that return JSON data, either validated or unvalidated, OpenAPI server supports an `Accept` header extension, provided using the HTTP-standard `Accept` header extension mechanism, which allows an API consumer to request slim responses stripped of all properties that contain a `null` value. As a peer to the normal "q" weight parameter in the `Accept` header, a "filterNulls" parameter with a true value ("t" or "1") will enable this mode.
+
+This "filterNulls" behavioral modifier can also be supplied via a new HTTP header called `X-EG-OpenAPI-Options`.
+
+Adding this option through either header can reduce the size of most responses that contain Fieldmapper objects by more than 50%.
+
+==== Default Endpoint Set ====
+
+The default endpoint set (with endpoints listed for convenience) to permission set mapping is:
+
+[width="100%",cols="18%,37%,45%",options="header",]
+|===
+|*endpoint_set* |*endpoints* |*perm_sets*
+|orgs a|
+retrieveFullOrgTree
+
+retrieveOneOrg
+
+retrieveOrgList
+
+retrievePartialOrgTree
+
+a|
+"Orgs - API only" (API_LOGIN, REST.api, REST.api.orgs)
+
+"Orgs - standard permissions" (OPAC_LOGIN)
+
+|collections a|
+collectionsPatronsOfInterest
+
+collectionsPatronsOfInterestWarning
+
+a|
+"Collections - API only" (API_LOGIN, REST.api, REST.api.collections)
+
+"Collections - standard permissions" (STAFF_LOGIN, VIEW_USER)
+
+|holds a|
+retrieveHold
+
+retrieveHoldPickupLocations
+
+a|
+"Holds - API only" (API_LOGIN, REST.api, REST.api.holds)
+
+"Holds - standard permissions" (STAFF_LOGIN, VIEW_USER)
+
+|self a|
+authenticateUser
+
+cancelSelfHold
+
+checkinSelfCirc
+
+logoutUser
+
+renewSelfCirc
+
+requestSelfCirc
+
+requestSelfHold
+
+retrieveSelfCirc
+
+retrieveSelfCircHistory
+
+retrieveSelfCircs
+
+retrieveSelfHold
+
+retrieveSelfHolds
+
+retrieveSelfProfile
+
+retrieveSelfXact
+
+retrieveSelfXacts
+
+selfActivePenalties
+
+selfPenalty
+
+selfUpdateParts
+
+updateSelfHold
+
+a|
+"Self - API only" (API_LOGIN, REST.api)
+
+"Self - standard permissions" (OPAC_LOGIN)
+
+|courses a|
+activeCourses
+
+activeRoles
+
+retrieveCourse
+
+retrieveCourseMaterials
+
+retrieveCourseUsers
+
+a|
+"Courses - API only" (API_LOGIN, REST.api)
+
+"Courses - standard permissions" (STAFF_LOGIN)
+
+|patrons a|
+cancelPatronHold
+
+checkinPatronCirc
+
+collectionsGetPatronDetail
+
+collectionsPutPatronInCollections
+
+collectionsRemovePatronFromCollections
+
+patronATEvent
+
+patronATEvents
+
+patronActiveMessages
+
+patronActivePenalties
+
+patronActivityLog
+
+patronMessage
+
+patronMessageArchive
+
+patronMessageUpdate
+
+patronPenalty
+
+renewPatronCirc
+
+requestPatronCirc
+
+requestPatronHold
+
+retrievePatronCirc
+
+retrievePatronCircHistory
+
+retrievePatronCircs
+
+retrievePatronHold
+
+retrievePatronHolds
+
+retrievePatronProfile
+
+retrievePatronXact
+
+retrievePatronXacts
+
+searchPatrons
+
+updatePatronHold
+
+verifyUserCredentials
+
+a|
+"Patrons - API only" (API_LOGIN, REST.api, REST.api.patrons)
+
+"Patrons - standard permissions" (STAFF_LOGIN, VIEW_USER)
+
+|items a|
+createItem
+
+deleteItem
+
+newItems
+
+retrieveItem
+
+updateItem
+
+a|
+"Items - API only" (API_LOGIN, REST.api, REST.api.items)
+
+"Items - standard permissions" (OPAC_LOGIN)
+
+|bibs a|
+bibDisplayFields
+
+createOneBib
+
+deleteOneBib
+
+retrieveOneBib
+
+retrieveOneBibHoldings
+
+updateBREParts
+
+updateOneBib
+
+a|
+"Bibs - API only" (API_LOGIN, REST.api, REST.api.bibs)
+
+"Bibs - standard permissions" (OPAC_LOGIN)
+
+|===
+
+== Output Filtering ==
+
+Available properties on Fieldmapper object output can be restricted through the use of several Library and User settings. These settings define whitelisted and blacklisted properties, with blacklisted property removal taking precedence over whitelisted property inclusion, where different settings conflict.
+
+=== API-global property filtering configuration ===
+
+The content of these settings is a comma-separated list of values, which are made up of the Fieldmapper class hint and the specific Fieldmapper property to be addressed. For example, to change the visibility of the
+Date of Birth column from the `actor.usr table`, one would add `au.dob` to the appropriate setting value, where "au" is the Fieldmapper class hint for the class representing the actor.usr table in the database, and "dob" is the Fieldmapper property representing, and column name, which holds the date of birth data.
+
+Whitelisting one or more properties on a Fieldmapper class will cause all other properties to be redacted by setting their values to `null`. Blacklisting one or more properties will cause only those named properties to be redacted in this way.
+
+Library settings are evaluated in the context of the Integrator user's home library, and are inheritable from parent locations in the same manner as all other Library settings. Two Library settings are for
+API-global use:
+
+* REST.api.whitelist_properties - Allow a particular set of properties, and only those properties, to be delivered to the Integrator across all API endpoints.
+* REST.api.blacklist_properties - Disallow a particular set of properties to be delivered to the Integrator across all API endpoints.
+
+User settings can also be used to add Integrator-specific whitelist and blacklist rules. To apply API-global, Integrator-specific properties restrictions, two new stock User Setting Types are available, with the same name and function as the Library settings above. Because these are user-specific, administrators can provide specific Integrator accounts access to more data with more whitelisted properties, or further restrict access by adding additional blacklist properties.
+
+=== Endpoint specific property restrictions ===
+
+In addition to the API-global Library and User settings, administrators can create new Organizational Unit Setting Types (AKA Library Setting Types) and User Setting Types through the Server Administration interface. Setting types must be named following a specific pattern. The setting types must start with the string `REST.api.whitelist_properties.` or `REST.api.blacklist_properties.` for whitelisting or blacklisting respectively -- note the period -- and end with the endpoint's Operation ID. For example, if an administrator would like to restrict the output of the `searchPatron` endpoint so that it only returns the idof the patron, and the client is expected to retrieve the full patron record separately, they could configure following:
+
+* Create a new Organizational Unit Setting Type with the name: `REST.api.whitelist_properties.searchPatron`
+* Set the value of the new Library Setting to `au.id` at the top of the Organizational hierarchy in the Library Settings Editor.
+
+User Setting Types can be created through the Server Administration interface, as well. However, because there is no Staff Client interface for general User Setting maintenance, the `api_ctl` tool, discussed below, must be used to configure Integrator-specific whitelist and blacklist rules. Integrator-specific setting types are created automatically by `api_ctl` if they do not already exist for a particular endpoint, so administrators are not required to create User Setting Types by hand when using the `api_ctl` tool.
+
+=== Applicability and scope ===
+
+Both whitelist and blacklist settings can be used at the same time, though the one primary case for this would be to add a property to a more specific blacklist when it is already present on a general whitelist. This may be useful in the case where general API access should allow retrieval of a particular piece of information, but a specific integration should not provide this information because it is likely to be visible through some third party interface if it is delivered to the client application.
+
+This output property restriction mechanism depends upon Evergreen's knowledge of the Integrator account making the request. In practice this means that all endpoints with the exception of the primary authentication endpoint, which only delivers an authentication token, will be protected using this feature. However, if local, custom endpoints are configured for an Evergreen instance that do not use the built-in security mechanisms, they cannot make use of this property restriction feature.
+
+== OpenAPI server configuration ==
+
+As mentioned above regarding the promotion of normal Evergreen user accounts to API Integrators, a new command line tool, called `api_ctl`, is available to configure and control the OpenAPI backend setup.
+
+This tool presents a text menu-driven interface for administrators to configure the backend. In many situations, the tool can also be used with direct command line parameters when the administrator knows the menu path they would normally take to effect the desired configuration change.
+
+A step-by-step example of this process is given below in the section xref:integrations:restful_api.adoc#adding_endpoints_example[Example: Adding endpoints to Evergreen’s OpenAPI server].
+
+Once the desired configuration changes have been made, all instances of the API server must be restarted in order to load the new configuration.
+
+=== Menu hierarchy ===
+
+There are several standard options available at most levels of the menu hierarchy:
+
+* top - go to the top level of the command hierarchy
+* back - go "up" one level in the command hierarchy
+* show - display the details of the currently loaded context configuration objects
+* details - toggle whether additional information about the objects configured by the current hierarchical command set is shown with the show command
+* quit - leave the tool
+
+There are common commands available for many objects that can be controlled through the api_ctl interface. Generally, objects can be listed, added, edited, and removed with menu options that are:
+
+* list - Show a summary list of the objects of the type controlled by the current level of the menu hierarchy.
+* load - Set the current context object to be configured, based on the current level of the menu hierarchy.
+* unload - Unset the current context object the type of which is based on the current level of the menu hierarchy.
+* add - Add a new object of the type controlled by the current level of the menu hierarchy.
+* edit - Edit the current context object controlled by the current level of the menu hierarchy.
+* remove - Remove an object controlled by the current level of the menu hierarchy.
+
+You can see all options available at the current level of the option hierarchy by pressing the tab key.
+
+The layout of the menu hierarchy when started without command line options is as follows:
+
+* api - Control API endpoint configuration
+** endpoints - Configure endpoints
+*** list - List all endpoints
+*** load - Set the current context endpoint
+*** unload - Unset the current context endpoint
+*** add
+*** edit
+*** remove
+*** activate - Activate an inactive endpoint
+*** deactivate - Deactivate an active endpoint
+*** parameters - Configure the Parameters of the context endpoint
+**** list - List the OpenAPI Parameters of the context endpoint
+**** add
+**** edit
+**** remove
+*** responses - Configure the Response structure of the context endpoint
+**** list - List all Responses configured for the context endpoint
+**** add
+**** edit
+**** remove
+*** sets - Add and remove endpoint Set mappings for the context endpoint
+**** list - List the endpoint set to which the endpoint is assigned
+**** add - Add the context endpoint to an endpoint Set
+**** remove - Remove the context endpoint from an endpoint Set
+*** perm_sets - Manage permission sets assigned to the context endpoint
+**** list - List all permission sets assigned to the context endpoint
+**** add - Add a permission set to allow access to the context endpoint
+**** remove - Remove an assigned permission set from the context endpoint
+*** rate_limits - Used to manage the endpoint-specific rate-limiting configuration for the context endpoint
+**** ip_address - Manage IP address-based rate limiting
+***** list
+***** add
+***** edit
+***** remove
+**** integrator - Manage Integrator-based rate limiting
+***** list
+***** add
+***** edit
+***** remove
+** sets - Configure endpoint sets
+*** list - List all endpoint sets
+*** load - Set the current context endpoint set
+*** unload - Unset the current context endpoint set
+*** add
+*** edit
+*** remove
+*** activate - Activate an inactive endpoint set
+*** deactivate - Deactivate an active endpoint set
+*** endpoints - Manage endpoints mapped into the current context endpoint set
+**** list - List all endpoints in the current context endpoint set
+**** add - Add an endpoint to the current context endpoint set
+**** remove - Remove an endpoints from the current context endpoint set
+*** perm_sets - Manage permission sets assigned to the context endpoint set
+**** list - List all permission sets assigned to the context endpoint set
+**** add - Add a permission set to allow access to the context endpoint set
+**** remove - Remove an assigned permission set from the context endpoint set
+*** rate_limits Used to manage the endpoint set-wide rate-limiting configuration for the context endpoint set
+**** ip_address - Manage IP address-based rate limiting for the endpoint set
+***** list
+***** add
+***** edit
+***** remove
+**** integrator - Manage Integrator-based rate limiting for the endpoint set
+***** list
+***** add
+***** edit
+***** remove
+* integrator - Manage Integrator-promoted Evergreen user accounts
+** list - List all integrators
+** load - Set the current context Integrator
+** unload - Unset the current context Integrator
+** add - Promote an Evergreen user account to an API Integrator account
+** remove - Remove the Integrator promotion from an Evergreen user account
+** enable - Enable a disabled Integrator for API login
+** disable - Disable an enabled Integrator for API login
+** password - Set the API-specific password for the context integrator
+** permissions - Manage the user-specific permissions granted to an Integrator
+*** list - List the permissions granted to the current context Integrator
+*** add - Add a permission to the Integrator
+*** remove - Remove a permission from the Integrator
+** groups - Manage the Secondary Permission Groups to which an Integrator
+belongs
+*** list - List the current context Integrator's Secondary Permission Groups
+*** add - Add the Integrator to a Secondary Permission Group
+*** remove - Remove the Integrator from a Secondary Permission Group
+** global_property_whitelist - Manage the Integrator-specific, API-global Fieldmapper property whitelist for an Integrator
+*** list - Show the current Integrator-specific, API-global whitelist
+*** set - Edit the current Integrator-specific, API-global whitelist
+*** remove - Remove the current Integrator-specific, API-global whitelist
+** global_property_blacklist - Manage the Integrator-specific, API-global Fieldmapper property blacklist for an Integrator
+*** list - Show the current Integrator-specific, API-global blacklist
+*** set - Edit the current Integrator-specific, API-global blacklist
+*** remove - Remove the current Integrator-specific, API-global blacklist
+** endpoint_property_whitelist - Manage the Integrator-specific, endpoint-specific Fieldmapper property whitelists for an Integrator
+*** list - List the current endpoint-specific whitelist
+*** add - Add one endpoint-specific whitelist
+*** edit - Edit one endpoint-specific whitelist
+*** remove - Remove one endpoint-specific whitelist
+** endpoint_property_blacklist - Manage the Integrator-specific,
+endpoint-specific Fieldmapper property blacklists for an Integrator
+*** list - List all current endpoint-specific blacklist
+*** add - Add one endpoint-specific blacklist
+*** edit - Edit one endpoint-specific blacklist
+*** remove - Remove one endpoint-specific blacklist
+* control
+** rate_limit - Manage rate limit definitions
+*** list - List all rate limit definitions
+*** load - Set the current context rate limit definition
+*** unload - Unset the current context rate limit definition
+*** add
+*** edit
+*** remove
+** perm_sets - Manage permission sets
+*** list - List all permission sets
+*** load - Set the current context permission set
+*** unload - Unset the current context permission set
+*** add
+*** edit
+*** remove
+*** permissions - Manage the permissions attached to the current context
+permission set
+**** list - List all permissions attached to the current context permission set
+**** add - Add a permission to the current context permission set
+**** remove - Remove a permission from the current context permission set
+
+== Testing and Using the API ==
+
+The Evergreen OpenAPI endpoints are not meant for use by humans directly in a browser URL bar, though it does use the same underlying protocols and mechanisms as a user-oriented interface. Instead, it is meant for programmatic access using standard OpenAPI calling semantics, as described by https://swagger.io/docs/specification/v3_0/about/[https://swagger.io/docs/s….
+
+The way OpenAPI clients and servers work together in Evergreen, through this development specifically, is as follows:
+
+. The server software produces an API specification document in JSON or YAML. This is available at https://<hostname>/openapi3/v1 on any fully installed instance of this development.
+. The client software consumes that specification document in order to understand what API calls are available, how it should send parameter data to the API calls, and what the format of the output of an API call will look like.
+
+There are standard tools to automate much of the client-side work, but the result of creating a functioning OpenAPI client will be an application making HTTP requests.
+
+There are many OpenAPI library generators available for use by developers for most common client programming languages. Among them are:
+
+* For general use:
+** https://openapi-generator.tech/[OpenAPI Generator]
+** https://swagger.io/tools/swagger-codegen/[API Code & Client Generator | Swagger Codegen]
+** OpenAPIs.org https://tools.openapis.org/categories/sdk.html[SDK] generator listing
+* Perl client libraries
+** https://metacpan.org/pod/OpenAPI::Client[OpenAPI::Client - A client for talking to an Open API powered server - metacpan.org]
+
+=== Authentication and authorization ===
+
+Obtaining an Evergreen auth token is accomplished by sending an HTTP *GET* request to the `/self/auth` path with the appropriate credentials.
+
+The credential parameter names are described in the API specification document. They can be passed using either
+
+. A standard HTTP Basic authorization header (with an optional third component for the login type); or
+. Through query parameters with the names `u` for username, `p` for password, and `t` for login type.
+
+Using the standard HTTP content negotiation `Accept` header, the client can ask for the token to be delivered as either plain text, which is useful for tasks like direct testing and high-level shell scripting, or as JSON data, which is the default and is usually better for use by actual client applications.
+
+The output of this request, which is a standard Evergreen auth token used by all authenticated client code in Evergreen, is then used in a standard HTTP Bearer authentication header to identify the session in later requests. For ease of testing and some added flexibility, the auth token may also be passed in the URL as a query parameter called `ses`, or in either of the modern Evergreen-standard cookies called `eg.auth.token` and `ses`, or the new, Evergreen OpenAPI-specific cookie called `eg.api.token`.
+
+=== Other testing tools ===
+
+Administrators can install Swagger-UI visualization tools so that developers can see the list of endpoints. These tools make use of the API specification document to assist with authorization and help the user authenticate with the API.
+
+NOTE: The JSON Schema is very large, and tends to cause the both locally hosted demonstration Swagger-UI visualization tools and the free editor hosted at editor.swagger.io to hang. This development intentionally creates a run-time translation mapping between Evergreen's Fieldmapper data structures that describe Evergreen data layout and standard JSON Schema object definitions, and there are many hundreds of object types that reference each other.
+
+The web-based Swagger-UI editor and visualization tools are not robust or sophisticated enough to handle such a large and complex component schema. This is a limitation of the basic demo Swagger tools. True client applications do not try to render the full JSON Schema, are written to be robust and correct, and are not expected to have these sorts of issues if they are designed well.
+
+[[adding_endpoints_example]]
+== Example: Adding endpoints to Evergreen's OpenAPI server ==
+
+This demonstration makes use of the https://github.com/EquinoxOpenLibraryInitiative/evg-api-explorer[Evergreen API Explorer], a Vue-based OpenSRF API exploration tool created at Equinox as a replacement for the original docgen.xsl OpenSRF API documentation publishing mechanism.
+
+=== User Id by barcode or username ===
+
+Using the Evergreen API Explorer, we can see that the open-ils.actor application provides several methods that may suit our purpose here.
+
+image::restful_api/api_explorer.png[Evergreen API Explorer]
+
+The parameter documentation is not great for any of these, but we can see where to find the underlying code by expanding the block.
+
+image::restful_api/block_expanded.png[Expand the block to view details]
+
+The implementation for `open-ils.actor.user.retrieve_id_by_barcode_or_username` is in the OpenILS::Application::Actor Perl module, in the sub named `retrieve_usr_id_via_barcode_or_usrname`. Using this information, we can see that the parameters expected by the OpenSRF method are an authentication token, an optional barcode, and an optional username.
+
+image::restful_api/expected_parameters.png[Example of expected parameters]
+
+We also see that the method will return either an numeric user id, or an ILS Event object upon error or permission restriction.
+
+image::restful_api/method_details.png[Example of method details]
+
+Using this information, we can immediately provide RESTful OpenAPI endpoints to return a user id by either barcode or username simply by
+wrapping the OpenSRF method directly. We can either insert the necessary
+endpoint configuration directly into the database, or use the api_ctl
+tool to configure the new endpoints.
+
+.Direct SQL configuration
+[source, postgresql]
+----
+BEGIN;
+
+INSERT INTO openapi.endpoint (
+ operation_id, path, http_method,
+ summary,
+ method_source, method_name,
+ method_params
+) VALUES (
+ 'patronIdByCardBarcode', '/patrons/by_barcode/:barcode/id', 'get',
+ 'Retrieve patron id by barcode',
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+'eg_auth_token param.barcode'
+);
+
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+('patronIdByCardBarcode','barcode','path','string',TRUE);
+
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES
+('patronIdByCardBarcode','integer');
+
+INSERT INTO openapi.endpoint (
+ operation_id, path, http_method,
+ summary,
+ method_source, method_name,
+ method_params
+) VALUES (
+ 'patronIdByUsername', '/patrons/by_username/:username/id', 'get',
+ 'Retrieve patron id by username',
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+'eg_auth_token "" param.username'
+);
+
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+('patronIdByUsername','username','path','string',TRUE);
+
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES
+('patronIdByUsername','integer');
+
+INSERT INTO openapi.endpoint_set_endpoint_map (endpoint, endpoint_set)
+ SELECT operation_id, 'patrons' FROM openapi.endpoint WHERE operation_id like 'patronIdBy%';
+
+COMMIT;
+----
+
+.api_ctl configuration
+[source, console]
+----
+$ api_ctl -- api endpoints add
+ # ... supply information about patronIdByCardBarcode endpoint, as above
+API Endpoint actions: parameters
+API Endpoint Parameter actions: add
+ # ... supply information about patronIdByCardBarcode path parameter
+API Endpoint Parameter actions: back
+API Endpoint actions: responses
+ # ... supply information about patronIdByCardBarcode success response
+API Endpoint Responses actions: back
+API Endpoint actions: sets
+API Endpoint Assigned Sets actions: add
+API Set: patrons
+API Endpoint Assigned Sets actions: back
+API Endpoint actions: add
+ # ... supply information about patronIdByUsername endpoint
+API Endpoint actions: parameters
+API Endpoint Parameter actions: add
+ # ... supply information about patronIdByUsername path parameter
+API Endpoint Parameter actions: back
+API Endpoint actions: responses
+ # ... supply information about patronIdByUsername success response
+API Endpoint Responses actions: back
+API Endpoint actions: sets
+API Endpoint Assigned Sets actions: add
+API Set: patrons
+API Endpoint Assigned Sets actions: quit
+----
+
+=== User Object by barcode or username ===
+
+Likewise, we can use just a small amount of additional code to create an endpoint to return the full user object in the same format as is returned by the `/patron/:id` endpoint path. As this new method will be a stock endpoint, we will add it to the built-in OpenILS::OpenAPI::Controller::patron Perl module, but it can live anywhere that the Perl interpreter can find modules.
+
+image:restful_api/create_endpoint.png[Creating an endpoint]
+
+The OpenILS::OpenAPI::Controller module namespace contains many endpoint examples and helper methods that are useful for the creation of OpenAPI endpoints.
+
+The parameters passed to the handler functions are exactly those that are defined for the OpenAPI endpoint, via its parameter list, following an invocant passed in the first parameter position. The effective invocant is the active Mojolicious Controller object. Extensive https://docs.mojolicious.org/Mojolicious[[documentation] on the https://docs.mojolicious.org/Mojolicious/Controller[Mojolicious Controller] is available with the Mojolicious Perl module.
+
+And, as before, we can then register the new endpoints with the OpenAPI server either using direct SQL or the api_ctl tool.
+
+.Direct SQL configuration
+[source, postgresql]
+----
+BEGIN;
+
+INSERT INTO openapi.endpoint (
+ operation_id, path, http_method,
+ summary,
+ method_source, method_name,
+ method_params
+) VALUES (
+ 'patronByCardBarcode', '/patrons/by_barcode/:barcode', 'get',
+ 'Retrieve patron id by barcode',
+ 'OpenILS::OpenAPI::Controller::patron','user_by_identifier_string',
+ 'eg_auth_token param.barcode'
+);
+
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+('patronByCardBarcode','barcode','path','string',TRUE);
+
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES
+('patronByCardBarcode','au');
+
+INSERT INTO openapi.endpoint (
+ operation_id, path, http_method,
+ summary,
+ method_source, method_name,
+ method_params
+) VALUES (
+ 'patronByUsername, '/patrons/by_username/:username', 'get',
+ 'Retrieve patron id by username',
+ 'OpenILS::OpenAPI::Controller::patron','user_by_identifier_string',
+ 'eg_auth_token "" param.username'
+);
+
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+('patronByUsername','username','path','string',TRUE);
+
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES
+('patronByUsername','au');
+
+INSERT INTO openapi.endpoint_set_endpoint_map (endpoint, endpoint_set)
+ SELECT operation_id, 'patrons' FROM openapi.endpoint WHERE operation_id like 'patronBy%';
+
+COMMIT;
+----
+
+.api_ctl configuration
+[source, console]
+----
+$ api_ctl -- api endpoints add
+ # ... supply information about patronByCardBarcode endpoint, as above
+API Endpoint actions: parameters
+API Endpoint Parameter actions: add
+ # ... supply information about patronByCardBarcode path parameter
+API Endpoint Parameter actions: back
+API Endpoint actions: responses
+ # ... supply information about patronByCardBarcode success response
+API Endpoint Responses actions: back
+API Endpoint actions: sets
+API Endpoint Assigned Sets actions: add
+API Set: patrons
+API Endpoint Assigned Sets actions: back
+API Endpoint actions: add
+ # ... supply information about patronByUsername endpoint
+API Endpoint actions: parameters
+API Endpoint Parameter actions: add
+ # ... supply information about patronByUsername path parameter
+API Endpoint Parameter actions: back
+API Endpoint actions: responses
+ # ... supply information about patronByUsername success response
+API Endpoint Responses actions: back
+API Endpoint actions: sets
+API Endpoint Assigned Sets actions: add
+API Set: patrons
+API Endpoint Assigned Sets actions: quit
+----
+
+=== Enabling the new endpoints ===
+
+The OpenAPI server must be restarted once any new endpoint configuration is applied. This allows the OpenAPI server to read the new endpoint configuration and add the appropriate routes and handlers.
commit 862ad66d1d6e62e649abb3fcbf67d87def0b679a
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Tue Dec 10 12:53:00 2024 -0500
LP#2067414: OpenAPI Infrastructure
This commit implements an OpenAPI interface against which external
clients can be developed.
Components consist of:
* Database and IDL data structures used to configure and manage the
OpenAPI implementation.
* A command line configuration tool for managing the OpenAPI endpoints,
API Integrator user accounts, permission and data visibility
restrictions, and rate limiting settings.
* A stand-alone OpenSRF client script implementing an OpenAPI server
using configuration data stored in the Evergreen database.
* Seed data creating a set of OpenAPI endpoints useful for interacting
with:
- the Evergreen authentication infrastructure
- your own account-related data
- other patrons' records
- transactional records (holds, circs)
- item records
- bibliographic records
- course reserves
Release-note: Implements an OpenAPI server for Evergreen
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Ruth Frasur Davis <rdavis(a)evergreencdi.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 1a46e9a133..b8e9d0ef68 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -4197,7 +4197,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
</links>
<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
<actions>
- <retrieve permission="VIEW_USER user_request.view" context_field="home_ou"/>
+ <retrieve permission="VIEW_USER user_request.view" context_field="home_ou" owning_user="id"/>
</actions>
</permacrud>
</class>
@@ -4871,7 +4871,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
<actions>
<create permission="UPDATE_USER"><context link="usr" field="home_ou"/></create>
- <retrieve permission="VIEW_USER"><context link="usr" field="home_ou"/></retrieve>
+ <retrieve permission="VIEW_USER" owning_user="usr"><context link="usr" field="home_ou"/></retrieve>
<update permission="UPDATE_USER"><context link="usr" field="home_ou"/></update>
<delete permission="UPDATE_USER"><context link="usr" field="home_ou"/></delete>
</actions>
@@ -7527,7 +7527,7 @@ SELECT usr,
</links>
<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
<actions>
- <retrieve permission="VIEW_USER user_request.view">
+ <retrieve permission="VIEW_USER user_request.view" owning_user="usr">
<context link="usr" field="home_ou"/>
</retrieve>
</actions>
@@ -14400,6 +14400,468 @@ SELECT usr,
</permacrud>
</class>
+ <!-- OpenAPI class defs -->
+ <class id="ojsd" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::json_schema_datatype" oils_persist:tablename="openapi.json_schema_datatype" reporter:label="OpenAPI JSON Schema Datatype">
+ <fields oils_persist:primary="name">
+ <field name="name" reporter:label="Name" reporter:datatype="id"/>
+ <field name="label" reporter:label="Label" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="description" reporter:label="Description" reporter:datatype="text"/>
+ </fields>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="ojsf" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::json_schema_format" oils_persist:tablename="openapi.json_schema_format" reporter:label="OpenAPI JSON Schema Data Format">
+ <fields oils_persist:primary="name">
+ <field name="name" reporter:label="Name" reporter:datatype="id"/>
+ <field name="label" reporter:label="Label" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="description" reporter:label="Description" reporter:datatype="text"/>
+ </fields>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oint" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::integrator" oils_persist:tablename="openapi.integrator" reporter:label="OpenAPI Integrator">
+ <fields oils_persist:primary="id">
+ <field name="id" reporter:label="User/Integrator ID" reporter:datatype="id"/>
+ <field name="enabled" reporter:label="Enabled?" reporter:datatype="bool" oils_obj:required="true"/>
+ </fields>
+ <links>
+ <link field="id" reltype="has_a" key="id" map="" class="au"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve permission="ADMIN_OPENAPI" global_required="true"/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="orld" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::rate_limit_definition" oils_persist:tablename="openapi.rate_limit_definition" reporter:label="OpenAPI Endpoints" oils_persist:restrict_primary="100">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.rate_limit_definition_id_seq">
+ <field name="id" reporter:label="ID" reporter:datatype="id" reporter:selector="name"/>
+ <field name="name" reporter:label="Name" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="limit_interval" reporter:label="Interval" reporter:datatype="interval" oils_obj:required="true"/>
+ <field name="limit_count" reporter:label="Count" reporter:datatype="int" oils_obj:required="true"/>
+ </fields>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve permission="ADMIN_OPENAPI" global_required="true"/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="orlem" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_user_rate_limit_map" oils_persist:tablename="openapi.endpoint_user_rate_limit_map" reporter:label="User OpenAPI Endpoint Rate Limit Map">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_user_rate_limit_map_id_seq">
+ <field name="id" reporter:label="User Endpoint Rate Limit Map ID" reporter:datatype="id"/>
+ <field name="accessor" reporter:label="API Accessor" reporter:datatype="text"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link"/>
+ <field name="rate_limit" reporter:label="Rate Limit" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="accessor" reltype="has_a" key="name" map="" class="au"/>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="rate_limit" reltype="has_a" key="name" map="" class="orld"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve permission="ADMIN_OPENAPI" global_required="true"/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="orlesm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_set_user_rate_limit_map" oils_persist:tablename="openapi.endpoint_set_user_rate_limit_map" reporter:label="User OpenAPI Endpoint Set Rate Limit Map">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_user_rate_limit_map_id_seq">
+ <field name="id" reporter:label="User Endpoint Set Rate Limit Map ID" reporter:datatype="id"/>
+ <field name="accessor" reporter:label="API Accessor" reporter:datatype="text"/>
+ <field name="endpoint_set" reporter:label="Endpoint Set" reporter:datatype="link"/>
+ <field name="rate_limit" reporter:label="Rate Limit" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="accessor" reltype="has_a" key="name" map="" class="au"/>
+ <link field="endpoint_set" reltype="has_a" key="name" map="" class="oes"/>
+ <link field="rate_limit" reltype="has_a" key="name" map="" class="orld"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve permission="ADMIN_OPENAPI" global_required="true"/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="orleipm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_ip_rate_limit_map" oils_persist:tablename="openapi.endpoint_ip_rate_limit_map" reporter:label="IP OpenAPI Endpoint Rate Limit Map">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_ip_rate_limit_map_id_seq">
+ <field name="id" reporter:label="IP Endpoint Rate Limit Map ID" reporter:datatype="id"/>
+ <field name="ip_range" reporter:label="IP with CIDR Mask" reporter:datatype="text"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link"/>
+ <field name="rate_limit" reporter:label="Rate Limit" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="rate_limit" reltype="has_a" key="name" map="" class="orld"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve permission="ADMIN_OPENAPI" global_required="true"/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="orlesipm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_set_ip_rate_limit_map" oils_persist:tablename="openapi.endpoint_set_ip_rate_limit_map" reporter:label="IP OpenAPI Endpoint Set Rate Limit Map">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_ip_rate_limit_map_id_seq">
+ <field name="id" reporter:label="IP Endpoint Set Rate Limit Map ID" reporter:datatype="id"/>
+ <field name="ip_range" reporter:label="IP with CIDR Mask" reporter:datatype="text"/>
+ <field name="endpoint_set" reporter:label="Endpoint Set" reporter:datatype="link"/>
+ <field name="rate_limit" reporter:label="Rate Limit" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint_set" reltype="has_a" key="name" map="" class="oes"/>
+ <link field="rate_limit" reltype="has_a" key="name" map="" class="orld"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve permission="ADMIN_OPENAPI" global_required="true"/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oep" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint" oils_persist:tablename="openapi.endpoint" reporter:label="OpenAPI Endpoints">
+ <fields oils_persist:primary="operation_id">
+ <field name="operation_id" reporter:label="Operation ID" reporter:datatype="id"/>
+ <field name="path" reporter:label="Route Path" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="http_method" reporter:label="HTTP Method" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="summary" reporter:label="Summary" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="security" reporter:label="Security Scheme" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="rate_limit" reporter:label="Default Rate Limit" reporter:datatype="link"/>
+ <field name="method_source" reporter:label="Method Source (Perl Module or OpenSRF service)" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="method_name" reporter:label="Method Name (Perl or OpenSRF method)" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="method_params" reporter:label="Method Parameters (comma-separated list)" reporter:datatype="text"/>
+ <field name="active" reporter:label="Active" reporter:datatype="bool" oils_obj:required="true"/>
+ <field name="parameters" reporter:label="Route Parameters" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="responses" reporter:label="Route Responses" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="perms" reporter:label="Direct Permissions" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="perm_sets" reporter:label="Direct Permission Sets" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="endpoint_sets" reporter:label="Endpoint Sets" reporter:datatype="link" oils_persist:virtual="true"/>
+ </fields>
+ <links>
+ <link field="rate_limit" reltype="has_a" key="id" map="" class="orld"/>
+ <link field="parameters" reltype="has_many" key="endpoint" map="" class="oepparam"/>
+ <link field="responses" reltype="has_many" key="endpoint" map="" class="oepres"/>
+ <link field="perms" reltype="has_many" key="endpoint" map="perm" class="oep_perm_maps"/>
+ <link field="perm_sets" reltype="has_many" key="endpoint" map="perm_set" class="oep_perm_set_maps"/>
+ <link field="endpoint_sets" reltype="has_many" key="endpoint" map="endpoint_set" class="oes_maps"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oepparam" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_param" oils_persist:tablename="openapi.endpoint_param" reporter:label="OpenAPI Endpoint Parameter Schemas">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_param_id_seq">
+ <field name="id" reporter:label="Param Schema ID" reporter:datatype="id"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link" oils_obj:required="true"/>
+ <field name="name" reporter:label="Name" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="required" reporter:label="Required" reporter:datatype="bool" oils_obj:required="true"/>
+ <field name="in_part" reporter:label="In Part" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="fm_type" reporter:label="Fieldmapper hint" reporter:datatype="text"/>
+ <field name="schema_type" reporter:label="JSON Schema Datatype" reporter:datatype="link"/>
+ <field name="schema_format" reporter:label="JSON Schema Data Format" reporter:datatype="link"/>
+ <field name="array_items" reporter:label="JSON Schema Array Item Datatypes" reporter:datatype="link"/>
+ <field name="default_value" reporter:label="Default value" reporter:datatype="text"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="schema_type" reltype="has_a" key="name" map="" class="ojsd"/>
+ <link field="schema_format" reltype="has_a" key="name" map="" class="ojsf"/>
+ <link field="array_items" reltype="has_a" key="name" map="" class="ojsd"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oepres" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_response" oils_persist:tablename="openapi.endpoint_response" reporter:label="OpenAPI Endpoint Response Schemas">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_response_id_seq">
+ <field name="id" reporter:label="Response Schema ID" reporter:datatype="id"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link" oils_obj:required="true"/>
+ <field name="validate" reporter:label="Validate" reporter:datatype="bool"/>
+ <field name="status" reporter:label="Status Code" reporter:datatype="int"/>
+ <field name="content_type" reporter:label="Content Type" reporter:datatype="text"/>
+ <field name="description" reporter:label="Status Description" reporter:datatype="text"/>
+ <field name="fm_type" reporter:label="Fieldmapper hint" reporter:datatype="text"/>
+ <field name="schema_type" reporter:label="JSON Schema Datatype" reporter:datatype="link"/>
+ <field name="schema_format" reporter:label="JSON Schema Data Format" reporter:datatype="link"/>
+ <field name="array_items" reporter:label="JSON Schema Array Item Datatypes" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="schema_type" reltype="has_a" key="name" map="" class="ojsd"/>
+ <link field="schema_format" reltype="has_a" key="name" map="" class="ojsf"/>
+ <link field="array_items" reltype="has_a" key="name" map="" class="ojsd"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="ops" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::perm_set" oils_persist:tablename="openapi.perm_set" reporter:label="OpenAPI Permission Sets" oils_persist:restrict_primary="1001">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.perm_set_id_seq">
+ <field name="id" reporter:label="Permission Set ID" reporter:datatype="id"/>
+ <field name="name" reporter:label="Name" reporter:datatype="text"/>
+ <field name="perms" reporter:label="Permissions" reporter:datatype="link" oils_persist:virtual="true"/>
+ </fields>
+ <links>
+ <link field="perms" reltype="has_many" key="perm_set" map="perm" class="opspm"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="opspm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::perm_set_perm_map" oils_persist:tablename="openapi.perm_set_perm_map" reporter:label="OpenAPI Permission Set Permission Maps">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_perm_map_id_seq">
+ <field name="id" reporter:label="Endpoint Set Permission Map ID" reporter:datatype="id"/>
+ <field name="perm_set" reporter:label="Permission Set" reporter:datatype="link"/>
+ <field name="perm" reporter:label="Permission" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="perm_set" reltype="has_a" key="id" map="" class="ops"/>
+ <link field="perm" reltype="has_a" key="id" map="" class="ppl"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oes" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_set" oils_persist:tablename="openapi.endpoint_set" reporter:label="OpenAPI Endpoint Set">
+ <fields oils_persist:primary="name">
+ <field name="name" reporter:label="Set Name" reporter:datatype="id"/>
+ <field name="description" reporter:label="Set Description" reporter:datatype="text" oils_obj:required="true"/>
+ <field name="active" reporter:label="Active" reporter:datatype="bool" oils_obj:required="true"/>
+ <field name="endpoint" reporter:label="Endpoints" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="perm_set_maps" reporter:label="Permission Set Maps" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="perm_sets" reporter:label="Permission Sets" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="perms" reporter:label="Direct Permissions" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="rate_limit" reporter:label="Default Rate Limit" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="perm_set_maps" reltype="has_many" key="endpoint_set" map="" class="oes_perm_set_maps"/>
+ <link field="perm_sets" reltype="has_many" key="endpoint_set" map="perm_set" class="oes_perm_set_maps"/>
+ <link field="perms" reltype="has_many" key="endpoint_set" map="perm" class="oes_perm_maps"/>
+ <link field="endpoints" reltype="has_many" key="endpoint_set" map="endpoint" class="oes_maps"/>
+ <link field="rate_limit" reltype="has_a" key="id" map="" class="orld"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oes_maps" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_set_endpoint_map" oils_persist:tablename="openapi.endpoint_set_endpoint_map" reporter:label="OpenAPI Endpoint Set Map">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_endpoint_map_id_seq">
+ <field name="id" reporter:label="Endpoint Permission Map ID" reporter:datatype="id"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link"/>
+ <field name="endpoint_set" reporter:label="Endpoint Set" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="endpoint_set" reltype="has_a" key="name" map="" class="oes"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oes_perm_set_maps" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_set_perm_set_map" oils_persist:tablename="openapi.endpoint_set_perm_set_map" reporter:label="OpenAPI Endpoint Set Permission Set Maps">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_perm_set_map_id_seq">
+ <field name="id" reporter:label="Endpoint Set Permission Set Map ID" reporter:datatype="id"/>
+ <field name="endpoint_set" reporter:label="Endpoint Set" reporter:datatype="link"/>
+ <field name="perm_set" reporter:label="Permission Set" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint_set" reltype="has_a" key="name" map="" class="oes"/>
+ <link field="perm_set" reltype="has_a" key="id" map="" class="ops"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oep_perm_set_maps" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_perm_set_map" oils_persist:tablename="openapi.endpoint_perm_set_map" reporter:label="OpenAPI Endpoint Permission Set Maps">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_perm_set_map_id_seq">
+ <field name="id" reporter:label="Endpoint Permission Set Map ID" reporter:datatype="id"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link"/>
+ <field name="perm_set" reporter:label="Permission_set" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="name" map="" class="oep"/>
+ <link field="perm_set" reltype="has_a" key="id" map="" class="ops"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oes_perm_maps" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_set_perm_map" oils_persist:tablename="openapi.endpoint_set_perm_map" reporter:label="OpenAPI Endpoint Set Permission Maps">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_perm_map_id_seq">
+ <field name="id" reporter:label="Endpoint Set Permission Map ID" reporter:datatype="id"/>
+ <field name="endpoint_set" reporter:label="Endpoint Set" reporter:datatype="link"/>
+ <field name="perm" reporter:label="Permission" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint_set" reltype="has_a" key="name" map="" class="oes"/>
+ <link field="perm" reltype="has_a" key="name" map="" class="ppl"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="oep_perm_maps" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="openapi::endpoint_perm_map" oils_persist:tablename="openapi.endpoint_perm_map" reporter:label="OpenAPI Endpoint Permission Maps">
+ <fields oils_persist:primary="id" oils_persist:sequence="openapi.endpoint_set_endpoint_map_id_seq">
+ <field name="id" reporter:label="Endpoint Set Endpoint Map ID" reporter:datatype="id"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link"/>
+ <field name="perm" reporter:label="Permission" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="perm" reltype="has_a" key="name" map="" class="ppl"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <create permission="ADMIN_OPENAPI" global_required="true"/>
+ <retrieve/>
+ <update permission="ADMIN_OPENAPI" global_required="true"/>
+ <delete permission="ADMIN_OPENAPI" global_required="true"/>
+ </actions>
+ </permacrud>
+ </class>
+
+
+ <class id="oaal" controller="open-ils.cstore" oils_obj:fieldmapper="openapi::authen_attempt_log" oils_persist:tablename="openapi.authen_attempt_log" reporter:label="OpenAPI Authentication Attempt Log">
+ <fields oils_persist:primary="request_id">
+ <field name="request_id" reporter:label="Request ID" reporter:datatype="text"/>
+ <field name="attempt_time" reporter:label="Attempt Time" reporter:datatype="timestamp"/>
+ <field name="ip_addr" reporter:label="IP Address" reporter:datatype="text"/>
+ <field name="cred_user" reporter:label="User Credential" reporter:datatype="text"/>
+ <field name="token" reporter:label="Authentication Token" reporter:datatype="text"/>
+ <field name="dispatch_log_entry" reporter:label="Dispatch Log Entry" reporter:datatype="link" oils_persist:virtual="true"/>
+ </fields>
+ <links>
+ <link field="dispatch_log_entry" reltype="might_have" key="request_id" map="" class="oedl"/>
+ </links>
+ </class>
+
+ <class id="oeaal" controller="open-ils.cstore" oils_obj:fieldmapper="openapi::endpoint_access_attempt_log" oils_persist:tablename="openapi.endpoint_access_attempt_log" reporter:label="OpenAPI Endpoint Access Attempt Log">
+ <fields oils_persist:primary="request_id">
+ <field name="request_id" reporter:label="Request ID" reporter:datatype="text"/>
+ <field name="endpoint" reporter:label="Endpoint" reporter:datatype="link"/>
+ <field name="attempt_time" reporter:label="Request Time" reporter:datatype="timestamp"/>
+ <field name="ip_addr" reporter:label="IP Address" reporter:datatype="text"/>
+ <field name="accessor" reporter:label="API Accessor" reporter:datatype="text"/>
+ <field name="allowed" reporter:label="Access Permitted?" reporter:datatype="bool"/>
+ <field name="token" reporter:label="Authentication Token" reporter:datatype="text"/>
+ <field name="dispatch_log_entry" reporter:label="Dispatch Log Entry" reporter:datatype="link" oils_persist:virtual="true"/>
+ </fields>
+ <links>
+ <link field="endpoint" reltype="has_a" key="operation_id" map="" class="oep"/>
+ <link field="accessor" reltype="has_a" key="name" map="" class="au"/>
+ <link field="dispatch_log_entry" reltype="might_have" key="request_id" map="" class="oedl"/>
+ </links>
+ </class>
+
+ <class id="oedl" controller="open-ils.cstore" oils_obj:fieldmapper="openapi::endpoint_dispatch_log" oils_persist:tablename="openapi.endpoint_dispatch_log" reporter:label="OpenAPI Endpoint Dispatch Log">
+ <fields oils_persist:primary="request_id">
+ <field name="request_id" reporter:label="Request ID" reporter:datatype="text"/>
+ <field name="complete_time" reporter:label="Complete Time" reporter:datatype="timestamp"/>
+ <field name="error" reporter:label="Access Permitted?" reporter:datatype="bool"/>
+ <field name="authen_log_entry" reporter:label="Authentication Log Entry" reporter:datatype="link" oils_persist:virtual="true"/>
+ <field name="endpoint_log_entry" reporter:label="Endpoint Access Log Entry" reporter:datatype="link" oils_persist:virtual="true"/>
+ </fields>
+ <links>
+ <link field="authen_log_entry" reltype="might_have" key="request_id" map="" class="oaal"/>
+ <link field="endpoint_log_entry" reltype="might_have" key="request_id" map="" class="oeaal"/>
+ </links>
+ </class>
+
<!-- Simple Reporter classes are currently used only by the SR UI -->
<class id="srcirc" controller="simple-reporter.ui" oils_persist:virtual="true" reporter:label="Simple Reporter Circulation">
<oils_persist:source_definition><![CDATA[
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller.pm b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller.pm
new file mode 100644
index 0000000000..e941e35e1a
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller.pm
@@ -0,0 +1,149 @@
+package OpenILS::OpenAPI::Controller;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Application::AppUtils;
+use MIME::Base64;
+
+our $VERSION = 1;
+my $U = "OpenILS::Application::AppUtils";
+
+sub retrieve_one_object_via_pcrud {
+ my ($controller, $ses, $type, $pkey_value, $flesh_fields, $flesh_depth) = @_;
+ $flesh_fields ||= {};
+ $flesh_depth ||= 1;
+
+ my $method = "retrieve_$type";
+
+ my $o = new_editor(
+ personality => 'open-ils.pcrud',
+ authtoken => $ses
+ )->$method([
+ $pkey_value,
+ {flesh => $flesh_depth, flesh_fields => $flesh_fields}
+ ]);
+
+ $controller->res->code(404) unless ($o);
+ return $o;
+}
+
+sub authenticateUser {
+ my ($c, $u, $p, $t) = @_;
+
+ my ($type, $creds) = split ' ', $c->req->headers->authorization || '';
+ if ($creds and $type =~ /basic/i) {
+ $creds = decode_base64($creds);
+ ($u,$p,$t) = split ':', $creds;
+ }
+
+ $t ||= 'api'; # Default to requiring the API_LOGIN permission and an integrator account
+
+ my $auth = {};
+ if ($u) {
+ my $throttle_user = check_auth_limits($u, $c->forwarded_for);
+
+ if ($throttle_user) {
+ log_authentication_attempt($c, $u);
+ $c->res->code(429);
+ $c->res->headers->header('Retry-After' => $throttle_user);
+ return $auth;
+ } elsif ($p) {
+ $c->app->log->trace("Attempting login for user $u, login type $t");
+ $auth = $U->simplereq(
+ 'open-ils.auth', 'open-ils.auth.login',
+ { username => $u,
+ password => $p,
+ type => $t
+ }
+ );
+
+ if (!$auth or !$auth->{textcode} or $auth->{textcode} ne 'SUCCESS') {
+ log_authentication_attempt($c, $u);
+ $c->res->code(401);
+ $c->res->message('Login failed');
+ return $auth;
+ } else {
+ log_authentication_attempt($c, $u, $auth->{payload}->{authtoken});
+ my $resp_type = $c->stash('eg_req_resolved_content_format') || 'json';
+ return $auth->{payload}->{authtoken} if ($resp_type eq 'text');
+ return { token => $auth->{payload}->{authtoken} };
+ }
+
+ } else {
+ log_authentication_attempt($c, $u);
+ $c->res->code(400);
+ return $auth;
+ }
+ }
+
+ log_authentication_attempt($c);
+ $c->res->code(400);
+ return $auth;
+}
+
+sub check_auth_limits {
+ my $username = shift;
+ my $ip = shift;
+
+ my $limits = new_editor()->json_query({from => ['openapi.check_auth_endpoint_rate_limit', $username, $ip]});
+ return $$limits[0]{'openapi.check_auth_endpoint_rate_limit'} if @$limits;
+
+ return undef; # proceed
+}
+
+sub log_authentication_attempt {
+ my $c = shift;
+ my $user = shift;
+ my $token = shift;
+
+ my $authen_attempt_log = Fieldmapper::openapi::authen_attempt_log->new;
+ $authen_attempt_log->request_id( $c->req->request_id );
+ $authen_attempt_log->ip_addr( $c->forwarded_for );
+ $authen_attempt_log->cred_user( $user );
+ $authen_attempt_log->token( $token );
+
+ my $e = new_editor(xact=>1);
+ $e->create_openapi_authen_attempt_log($authen_attempt_log);
+ $e->commit;
+}
+
+sub where_clause_from_triples {
+ my ($fields, $ops, $values) = @_;
+
+ my %search_parts;
+ while (my $f = shift @$fields) {
+ my $o = shift @$ops;
+ my $v = shift @$values;
+ $search_parts{$f} ||= [];
+ push @{$search_parts{$f}}, { $f => { $o => $v } };
+ }
+
+ my %search = ( '-and' => [] );
+ for my $f (keys %search_parts) {
+ my $p = $search_parts{$f};
+ if (@$p > 1) { # or them for the same field
+ push @{$search{'-and'}}, { '-or' => $p };
+ } else {
+ push @{$search{'-and'}}, $$p[0];
+ }
+ }
+
+ return \%search;
+}
+
+sub apply_blob_to_object {
+ my ($obj,$blob,$allowed) = @_;
+
+ my %defaults;
+ if (ref($allowed) eq 'HASH') {
+ %defaults = %$allowed;
+ $allowed = [ keys %defaults ];
+ }
+
+ my %parts = %$blob; # work on a copy
+ %parts = %parts{@$allowed}
+ if ($allowed and @$allowed); # trim if requested
+
+ $obj->$_(defined($parts{$_}) ? $parts{$_} : $defaults{$_}) for keys %parts;
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/bib.pm b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/bib.pm
new file mode 100644
index 0000000000..25dfc462bd
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/bib.pm
@@ -0,0 +1,236 @@
+package OpenILS::OpenAPI::Controller::bib;
+use OpenILS::OpenAPI::Controller;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Application::Cat::AssetCommon;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Const qw/:const/;
+use MARC::Record;
+
+our $VERSION = 1;
+our $U = "OpenILS::Application::AppUtils";
+
+sub fetch_one_bib {
+ my ($c, $bib) = @_;
+ my $bre = new_editor()->retrieve_biblio_record_entry($bib);
+ my $resp_type = $c->stash('eg_req_resolved_content_format') || 'json';
+ return $bre->marc if ($resp_type eq 'xml');
+ return MARC::Record->new_from_xml( $bre->marc, 'UTF-8', 'USMARC' )->as_usmarc if ($resp_type eq 'binary');
+ return $bre;
+}
+
+sub update_bre_parts {
+ my ($c, $ses, $bibid, $parts) = @_;
+ $parts ||= {};
+
+ my $e = new_editor(xact => 1, authtoken => $ses, personality => 'open-ils.pcrud');
+
+ my $bib = $e->retrieve_biblio_record_entry($bibid) || die 'Could not retrieve record';
+
+ for my $allowed_part ( qw/source owner share_depth/ ) {
+ if (exists $$parts{$allowed_part}) { # they want to set it to something...
+ if (defined $$parts{$allowed_part}) { # they want a value
+ $bib->$allowed_part($$parts{$allowed_part});
+ } else { # they want to unset the value
+ $allowed_part = 'clear_'.$allowed_part;
+ $bib->$allowed_part;
+ }
+ }
+ }
+
+ $e->update_biblio_record_entry($bib) || die 'Could not update record';
+ $e->commit;
+
+ return new_editor()->retrieve_biblio_record_entry($bibid);
+}
+
+sub fetch_one_bib_display_fields {
+ my ($c, $bib, $map) = @_;
+ $map ||= '""=>"-1"';
+
+ return $U->simplereq(
+ 'open-ils.search',
+ 'open-ils.search.fetch.metabib.display_field.highlight.fleshed',
+ $map => $bib
+ );
+}
+
+sub fetch_one_bib_holdings {
+ my ($c, $bib, $limit, $offset) = @_;
+
+ my $acn = new_editor()->search_asset_call_number([
+ { record => $bib,
+ label => { '<>' => '##URI##'},
+ deleted => 'f'
+ },
+ { order_by => { acn => [qw/label_sortkey label owning_lib/] },
+ flesh => 2,
+ flesh_fields => {
+ acn => [qw/copies prefix suffix/],
+ acp => [qw/status circ_lib location parts/]
+ }
+ }
+ ]) or die "cn tree fetch failed";
+
+ # flip CN->CP inside out
+ my @copies;
+ for my $cn (@$acn) {
+ push @copies, map {
+ $_->call_number($cn);
+ $_->circ_lib($_->circ_lib->id);
+ $_;
+ } grep {
+ !$U->is_true($_->deleted)
+ and $U->is_true($_->opac_visible)
+ and $U->is_true($_->status->opac_visible)
+ and $U->is_true($_->location->opac_visible)
+ and $U->is_true($_->circ_lib->opac_visible)
+ } sort {
+ $a->barcode cmp $b->barcode
+ } @{$cn->copies};
+ $cn->clear_copies
+ }
+
+ if ($limit) {
+ $offset ||= 0;
+ my $end_index = $offset + $limit - 1;
+ $end_index = scalar(@copies) - 1 if $end_index > scalar(@copies) - 1;
+ return [ @copies[$offset .. $end_index] ];
+ }
+
+ return \@copies;
+}
+
+sub item_by_barcode {
+ my ($c, $barcode) = @_;
+ my $copy = new_editor()->search_asset_copy({deleted => 'f', barcode => $barcode})->[0];
+ $c->res->code(404) if (!$copy);
+ return $copy;
+}
+
+sub fetch_new_items {
+ my ($c, $limit, $offset, $age) = @_;
+ $offset ||= 0;
+ $limit ||= 100;
+
+ my $filter = {deleted => 'f', active_date => {'!=' => undef}};
+ $$filter{active_date} = {
+ '>=' => {
+ transform => 'age',
+ params => ['now'],
+ value => '-' . $age
+ }
+ } if ($age);
+
+ my $order = {order_by => {acp => 'active_date DESC'}};
+ if ($limit) {
+ $$order{limit} = $limit;
+ $$order{offset} = $offset;
+ }
+
+ return new_editor()->search_asset_copy([ $filter, $order ]);
+}
+
+sub delete_one_item {
+ my ($c, $ses, $barcode) = @_;
+ my $e = new_editor(authtoken=>$ses, xact=>1);
+
+ my $copy = new_editor()->search_asset_copy({deleted => 'f', barcode => $barcode})->[0];
+ do { $c->res->code(404); return {error=>"No copy found with barcode $barcode"}; }
+ unless ($copy);
+
+ $evt = OpenILS::Application::Cat::AssetCommon->delete_copy(
+ $e, {all => 1}, $e->retrieve_asset_call_number($copy->call_number), $copy
+ );
+
+ if($evt) {
+ $e->rollback;
+ $c->res->code(400);
+ return $evt;
+ }
+
+ $e->commit;
+
+ return 1;
+}
+
+sub create_or_update_one_item {
+ my ($c, $ses, $copy_blob, $barcode) = @_;
+
+ my $copy;
+ my %parts = (
+ loan_duration => undef, fine_level => undef,
+ copy_number => undef,
+ mint_condition => undef, age_protect => undef,
+ location => undef, circ_lib => undef,
+ deposit => undef, deposit_amount => undef,
+ circulate => undef, ref => undef, holdable => undef,
+ price => undef, cost => undef,
+ dummy_isbn => undef, dummy_author => undef, dummy_title => undef,
+ circ_as_type => undef, circ_modifier => undef,
+ opac_visible => undef
+ );
+
+ my $e = new_editor(authtoken=>$ses, xact=>1);
+
+ if ($barcode) {
+ $copy = item_by_barcode($c, $barcode);
+ do { $c->res->code(404); return {error=>"No copy found with barcode $barcode"}; }
+ unless ($copy);
+
+ OpenILS::OpenAPI::Controller::apply_blob_to_object($copy, $copy_blob, \%parts);
+ $copy->ischanged(1);
+
+ $evt = OpenILS::Application::Cat::AssetCommon->update_fleshed_copies(
+ $e, {all => 1}, undef, [$copy]
+ );
+
+ if($evt) {
+ $e->rollback;
+ $c->res->code(400);
+ return $evt;
+ }
+ } else {
+ do { $c->res->code(400); return {error=>"No record supplied for item in 'bib' property"}; }
+ unless ($$copy_blob{bib} || $U->is_true($$copy_blob{precat}));
+
+ do { $c->res->code(400); return {error=>"No call number supplied for item in 'call_number' property"}; }
+ unless ($$copy_blob{call_number} || $U->is_true($$copy_blob{precat}));
+
+ $parts{status} = OILS_COPY_STATUS_IN_PROCESS;
+ $parts{loan_duration} = 2;
+ $parts{fine_level} = 2;
+ $parts{barcode} = undef;
+
+ my $evt;
+ my $vol;
+ if ($U->is_true($$copy_blob{precat})) {
+ $vol = $e->retrieve_asset_call_number(-1);
+ } else {
+ ($vol, $evt) = OpenILS::Application::Cat::AssetCommon->find_or_create_volume(
+ $e, $$copy_blob{call_number}, $$copy_blob{bib}, $$copy_blob{circ_lib}
+ );
+
+ if($evt) {
+ $e->rollback;
+ $c->res->code(400);
+ return $evt;
+ }
+ }
+
+ $copy = Fieldmapper::asset::copy->new;
+ OpenILS::OpenAPI::Controller::apply_blob_to_object($copy, $copy_blob, \%parts);
+
+ if($evt = OpenILS::Application::Cat::AssetCommon->create_copy($e, $vol, $copy)) {
+ $e->rollback;
+ $c->res->code(400);
+ return $evt;
+ }
+ }
+
+ $e->commit;
+
+ return $e->retrieve_asset_copy($copy->id);
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/course.pm b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/course.pm
new file mode 100644
index 0000000000..51d7693b05
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/course.pm
@@ -0,0 +1,60 @@
+package OpenILS::OpenAPI::Controller::course;
+use OpenILS::OpenAPI::Controller;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenILS::Application::AppUtils;
+
+our $VERSION = 1;
+our $U = "OpenILS::Application::AppUtils";
+
+sub get_active_courses {
+ my ($c, $ses, $orgs) = @_;
+
+ my $search = {is_archived => 'f'};
+ if ($orgs) {
+ if (ref($orgs) and ref($orgs) eq 'ARRAY') {
+ $orgs = [grep {/^\d+$/} @$orgs]; # just IDs, please
+ $$search{owning_lib} = $orgs if (@$orgs);
+ }
+ }
+
+ return new_editor(personality=>'open-ils.pcrud', authtoken=>$ses)->search_asset_course_module_course($search);
+}
+
+sub get_course_detail {
+ my ($c, $ses, $c_id) = @_;
+
+ my $course = new_editor(personality=>'open-ils.pcrud', authtoken=>$ses)->retrieve_asset_course_module_course([
+ $c_id, {
+ flesh => 1,
+ flesh_fields => {
+ acmc => [qw/owning_lib terms_map members/],
+ acmcu => [qw/usr_role/],
+ acmtcm => [qw/term/]
+ }
+ }
+ ]);
+
+ $course->members( [grep {$U->is_true($_->usr_role->is_public)} @{$course->members}] ); # only return is_public roles, if anything
+
+ return $course;
+}
+
+sub get_course_materials {
+ my ($c, $c_id) = @_;
+
+ return $U->simplereq(
+ 'open-ils.courses',
+ 'open-ils.courses.course_materials.retrieve.fleshed.atomic',
+ { course => $c_id }
+ );
+}
+
+sub get_all_course_public_roles {
+ return $U->simplereq( # so close! param-mapped literal must be a scalar, for now, so we have to wrap this call
+ 'open-ils.courses',
+ 'open-ils.courses.course_users.retrieve',
+ { '!=' => undef }
+ );
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/hold.pm b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/hold.pm
new file mode 100644
index 0000000000..8c03d26475
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/hold.pm
@@ -0,0 +1,117 @@
+package OpenILS::OpenAPI::Controller::hold;
+use OpenILS::OpenAPI::Controller;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenILS::Application::AppUtils;
+
+our $VERSION = 1;
+our $U = "OpenILS::Application::AppUtils";
+
+sub open_holds {
+ my ($c, $ses, $userid) = @_;
+ return $U->simplereq(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.details.batch.retrieve.atomic',
+ $ses,
+ $U->simplereq(
+ 'open-ils.circ',
+ 'open-ils.circ.holds.id_list.retrieve',
+ $ses, $userid
+ )
+ );
+}
+
+sub cancel_user_hold {
+ my ($c, $ses, $userid, $holdid, $reason) = @_;
+
+ my $old_hold = new_editor()->retrieve_action_hold_request($holdid);
+ if ($old_hold->usr ne $userid) {
+ $c->res->message('Cannot update hold for a different patron');
+ $c->res->code(403);
+ return undef;
+ }
+
+ my $res = $U->simplereq('open-ils.circ', 'open-ils.circ.hold.cancel', $ses, $holdid, $reason);
+
+ if ($res and !ref($res)) {
+ $res = { errors => 0 };
+ } else {
+ $c->res->code(403);
+ }
+
+ return $res;
+}
+
+sub update_user_hold {
+ my ($c, $ses, $userid, $holdid, $hold_patch) = @_;
+
+ my $old_hold = new_editor()->retrieve_action_hold_request($holdid);
+ if ($old_hold->usr ne $userid) {
+ $c->res->message('Cannot update hold for a different patron');
+ $c->res->code(403);
+ return undef;
+ }
+
+ $$hold_patch{id} = $holdid;
+ my $res = $U->simplereq('open-ils.circ', 'open-ils.circ.hold.update', $ses, undef, $hold_patch);
+
+ if ($res and !ref($res)) {
+ $res = { errors => 0 };
+ } else {
+ $c->res->code(403);
+ }
+
+ return $res;
+}
+
+sub request_hold {
+ my ($c, $ses, $user_id, $hold_parts) = @_;
+
+ if (!ref($hold_parts) or !($$hold_parts{bib} or $$hold_parts{copy}) or !$$hold_parts{pickup_lib}) {
+ $c->res->message('Invalid hold request');
+ $c->res->code(403);
+ return undef;
+ }
+
+ my $type = $$hold_parts{bib} ? 'T' : 'C';
+ my $new_hold = {
+ patronid => $user_id,
+ hold_type => $type,
+ pickup_lib => $$hold_parts{pickup_lib},
+ expire_time => $$hold_parts{expire_time},
+ };
+
+ my $target = [ $$hold_parts{bib} || $$hold_parts{copy} ];
+ my $result = $U->simplereq('open-ils.circ', 'open-ils.circ.holds.test_and_create.batch.override.atomic', $ses, $new_hold, $target)->[0];
+
+ $$result{error} = (ref($result) && ref($$result{result})) ? 1 : 0;
+ $c->res->code(403) if $$result{error};
+
+ return $result;
+}
+
+sub fetch_user_hold {
+ my ($controller, $ses, $usrid, $holdid) = @_;
+ my $res = $U->simplereq(
+ 'open-ils.circ', 'open-ils.circ.hold.details.retrieve',
+ $ses => $holdid
+ );
+
+ unless (ref($res) and $$res{hold} and $$res{hold}->usr == $usrid) {
+ $controller->res->code(403);
+ return undef;
+ }
+ return $res;
+}
+
+sub valid_hold_pickup_locations {
+ my $e = new_editor();
+ return $e->search_actor_org_unit({
+ opac_visible => 't',
+ ou_type => $e->search_actor_org_unit_type(
+ [{ can_have_vols => 't' }],
+ { idlist => 1 }
+ )
+ });
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/org.pm b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/org.pm
new file mode 100644
index 0000000000..ee7d571798
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/org.pm
@@ -0,0 +1,44 @@
+package OpenILS::OpenAPI::Controller::org;
+use OpenILS::OpenAPI::Controller;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenILS::Application::AppUtils;
+
+our $VERSION = 1;
+our $U = "OpenILS::Application::AppUtils";
+
+sub full_tree {
+ my ($c) = @_;
+ return one_tree($c, new_editor()->search_actor_org_unit({parent_ou => undef})->[0]->id);
+}
+
+sub one_tree {
+ my ($c, $org) = @_;
+
+ return new_editor()->retrieve_actor_org_unit([
+ $org,
+ {flesh => 100, flesh_fields => {aou => [qw/ou_type children/]}}
+ ]);
+}
+
+sub one_org {
+ my ($c, $org) = @_;
+ return new_editor()->retrieve_actor_org_unit([
+ $org,
+ {flesh => 1, flesh_fields => {aou => [qw/ou_type ill_address holds_address mailing_address billing_address/]}}
+ ]);
+}
+
+sub flat_org_list {
+ my ($c, $fields, $ops, $values) = @_;
+
+ my $where = ($fields and @$fields) ?
+ OpenILS::OpenAPI::Controller::where_clause_from_triples($fields, $ops, $values) :
+ {id => {'!=' => undef}};
+
+ return new_editor()->search_actor_org_unit([
+ $where,
+ {flesh => 1, flesh_fields => {aou => [qw/ou_type ill_address holds_address mailing_address billing_address/]}}
+ ]);
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/patron.pm b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/patron.pm
new file mode 100644
index 0000000000..b8d18463f4
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/patron.pm
@@ -0,0 +1,316 @@
+package OpenILS::OpenAPI::Controller::patron;
+use OpenILS::OpenAPI::Controller;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenILS::Application::AppUtils;
+
+our $VERSION = 1;
+our $U = "OpenILS::Application::AppUtils";
+
+sub deliver_user {
+ my ($controller, $ses, $usr) = @_;
+ return OpenILS::OpenAPI::Controller::retrieve_one_object_via_pcrud(
+ $controller => $ses,
+ actor_user => $usr => {au => [qw/card cards addresses profile groups/]}
+ );
+}
+
+# New subroutine for fleshing the user-id-by-barcode-or-username method output
+sub user_by_identifier_string {
+ my ($c, $ses, $barcode, $username) = @_;
+
+ my $uid = $U->simplereq(
+ 'open-ils.actor', 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+ $ses, $barcode, $username
+ );
+
+ do { $c->res->code(404); return $uid; } if ref($uid); # ILS Event objects are refs
+ return deliver_user($c, $ses, $uid);
+}
+
+sub find_users {
+ my ($c, $ses, $fields, $ops, $values, $limit, $offset) = @_;
+ $limit ||= 100;
+ $offset ||= 0;
+
+ do { $c->res->code(400); return {error=>"No search terms provided"}; }
+ unless ($fields and @$fields);
+
+ return new_editor(personality=>'open-ils.pcrud', authtoken=>$ses)->search_actor_user([
+ { -and => [
+ { deleted => 'f', active => 't' },
+ OpenILS::OpenAPI::Controller::where_clause_from_triples($fields, $ops, $values)
+ ]},
+ {limit => $limit, offset => $offset, flesh => 1,
+ flesh_fields => {au => [qw/card cards addresses profile groups/]},
+ order_by => {au => [qw/usrname/]}
+ }
+ ]);
+}
+
+sub confirm_circ_for_patron {
+ my ($circ_id, $must_match_user) = @_;
+
+ my $e = new_editor();
+ my $circ = $e->retrieve_action_circulation([
+ $circ_id, {flesh => 1, flesh_fields => { circ => ['target_copy'] }}
+ ]);
+
+ die 'invalid circulation id' unless $circ;
+
+ if ($must_match_user) { # if passed, make sure the session user owns the circ
+ die 'invalid circulation id' unless $circ->usr == $must_match_user;
+ }
+
+ return $circ;
+}
+
+sub circ_result_with_error_wrapper {
+ my $c = shift;
+ my $res = shift;
+ $res = [$res] if (ref($res) ne 'ARRAY');
+
+ my $errors = [ grep { $$_{textcode} ne 'SUCCESS' } @$res ];
+ $c->res->code(403) if @$errors;
+ return { errors => int(scalar(@$errors)), result => $res };
+}
+
+sub checkout_item {
+ my ($c, $ses, $userid, $copy_barcode) = @_;
+ return circ_result_with_error_wrapper($c,
+ $U->simplereq(
+ 'open-ils.circ', 'open-ils.circ.checkout.full', $ses,
+ { barcode => $copy_barcode, patron_id => $userid }
+ )
+ );
+}
+
+sub checkin_circ {
+ my ($c, $ses, $circid, $must_match_user) = @_;
+ return circ_result_with_error_wrapper($c,
+ $U->simplereq(
+ 'open-ils.circ', 'open-ils.circ.checkin', $ses,
+ { barcode => confirm_circ_for_patron($circid, $must_match_user)->target_copy->barcode, force => 1, noop => 1 }
+ )
+ );
+}
+
+sub renew_circ {
+ my ($c, $ses, $circid, $must_match_user) = @_;
+ my $circ = confirm_circ_for_patron($circid, $must_match_user);
+ return circ_result_with_error_wrapper($c,
+ $U->simplereq(
+ 'open-ils.circ', 'open-ils.circ.renew', $ses,
+ { copy_id => $circ->target_copy->id, patron_id => $circ->usr}
+ )
+ );
+}
+
+sub update_user_parts {
+ my ($c, $ses, $update_parts) = @_;
+
+ my $orig_pw = delete $$update_parts{current_password};
+
+ my %results;
+ for my $part ( keys %$update_parts ) {
+ my $res = $U->simplereq(
+ 'open-ils.actor', "open-ils.actor.user.$part.update",
+ $ses => $$update_parts{$part} => $orig_pw
+ ) or die "user update call failed";
+ if (ref($res)) {
+ $results{$part} = { success => 0, error => $res };
+ } else {
+ $results{$part} = { success => 1 };
+ }
+ }
+
+ return \%results;
+};
+
+sub circulation_history {
+ my ($c, $ses, $userid, $limit, $offset, $sort, $before, $after) = @_;
+ return transactions_by_state($c, $ses, $userid, 'all', $limit, $offset, $sort, $before, $after, 'circulation')
+}
+
+sub usr_at_events {
+ my ($c, $ses, $user_id, $limit, $offset, $before, $after, $hooks, $event_id) = @_;
+ my $e = new_editor(authtoken => $ses);
+
+ my $options = {
+ limit => $limit,
+ offset => $offset,
+ order_by => [{class=>'atev', field=>'run_time', direction=>'desc'}]
+ };
+
+ my $filter = {context_user => $user_id};
+ if ($before and $after) {
+ $$filter{run_time} = {'between' => [$after, $before]};
+ } elsif ($before) {
+ $$filter{run_time} = {'<' => $before};
+ } elsif ($after) {
+ $$filter{run_time} = {'>' => $after};
+ }
+
+ $$filter{event_def} = [
+ map {$_->id} @{$e->search_action_trigger_event_definition({hook=>$hooks})}
+ ] if ($hooks);
+
+ if ($event_id) {
+ $$filter{id} = $event_id;
+ $$options{flesh} = 2;
+ $$options{flesh_fields} = {
+ atevdef => [qw/hook owner validator reactor cleanup_success cleanup_failure opt_in_setting env params/],
+ atev => [qw/event_def template_output error_output async_output context_library context_bib context_item/],
+ };
+ }
+
+ my $events = new_editor(authtoken => $ses)->search_action_trigger_event([
+ $filter, $options
+ ]);
+
+ return [ map {$_->id} @$events] unless $event_id;
+ return $$events[0];
+}
+
+sub transactions_by_state {
+ my ($c, $ses, $user_id, $state, $limit, $offset, $sort, $before, $after, $type) = @_;
+ $sort = 'desc' if ($sort and !grep {uc($sort) eq $_} qw/ASC DESC/);
+
+ my $method = '';
+ if ($state eq 'all') {
+ $method = 'open-ils.actor.user.transactions.history';
+ } elsif (grep { $_ eq $state } qw/have_charge still_open have_balance have_bill have_bill_or_payment have_payment/) {
+ $method .= "open-ils.actor.user.transactions.history.$state";
+ }
+
+ die 'Invalid transaction type request' unless $method;
+
+ my $options = {
+ limit => $limit,
+ offset => $offset,
+ sort => uc($sort)
+ };
+
+ my $filters = {};
+ if ($before and $after) {
+ $$filter{xact_start} = {'between' => [$after, $before]};
+ } elsif ($before) {
+ $$filter{xact_start} = {'<' => $before};
+ } elsif ($after) {
+ $$filter{xact_start} = {'>' => $after};
+ }
+
+ return $U->simplereq(
+ 'open-ils.actor', $method, $ses, $user_id, $type, $filters, $options
+ );
+}
+
+sub update_usr_message {
+ my ($c, $ses, $ses_user, $userid, $message_id, $blob) = @_;
+ my $e = new_editor(personality => 'open-ils.pcrud', authtoken => $ses, xact=>1);
+ my $msg = $e->retrieve_actor_usr_message($message_id);
+
+ if (!$msg or $msg->usr != $userid) {
+ $e->rollback;
+ $c->res->code(404);
+ return undef;
+ }
+
+ my %parts = (
+ title => undef,
+ message => undef,
+ stop_date => undef,
+ pub => undef,
+ deleted => undef
+ );
+
+ OpenILS::OpenAPI::Controller::apply_blob_to_object($msg, $blob, \%parts);
+
+ $msg->editor($ses_user);
+ $msg->edit_date('now');
+
+ if (!$e->update_actor_usr_message($msg)) {
+ $e->rollback;
+ $c->res->code(403);
+ return undef;
+ }
+
+ $msg = $e->retrieve_actor_usr_message($message_id);
+ $e->commit;
+
+ return $msg;
+}
+
+sub archive_usr_message {
+ my ($c, $ses, $ses_user, $userid, $message_id) = @_;
+ return update_usr_message(
+ $c, $ses, $ses_user, $userid, $message_id, {deleted => 't'}
+ ) ? 1 : undef;
+}
+
+sub usr_messages {
+ my ($c, $ses, $userid, $pub_only, $message_id) = @_;
+
+ my $filter = {
+ usr => $userid,
+ deleted => 'f',
+ '-or' => [ {stop_date => undef}, {stop_date => {'>' => 'now'}} ]
+ };
+ $$filter{id} = $message_id if ($message_id);
+
+ my $messages = new_editor(
+ personality => 'open-ils.pcrud',
+ authtoken => $ses
+ )->search_actor_usr_message(
+ $filter
+ );
+
+ $messages = [grep {$U->is_true($_->pub)} @$messages]
+ if ($pub_only);
+
+ return [ map {$_->id} @$messages] unless $message_id;
+ return $$messages[0];
+}
+
+sub standing_penalties {
+ my ($c, $ses, $userid, $pub_only, $penalty_id) = @_;
+
+ my $filter = {
+ usr => $userid,
+ '-or' => [ {stop_date => undef}, {stop_date => {'>' => 'now'}} ]
+ };
+ $$filter{id} = $penalty_id if ($penalty_id);
+
+ my $penalties = new_editor(personality => 'open-ils.pcrud', authtoken => $ses)->search_actor_user_standing_penalty([
+ $filter,
+ { flesh => 1, flesh_fields => {ausp => ['standing_penalty','usr_message']} }
+ ]);
+
+ $penalties = [grep {$U->is_true($_->standing_penalty->pub) and $U->is_true($_->usr_message->pub)} @$penalties]
+ if ($pub_only);
+
+ return [ map {$_->id} @$penalties] unless $penalty_id;
+ return $penalties;
+}
+
+sub usr_activity {
+ my ($c, $ses, $user_id, $maxage, $limit, $offset, $sort) = @_;
+ $sort = 'desc' if (!$sort or ($sort and !grep {uc($sort) eq $_} qw/ASC DESC/));
+ $limit ||= 100;
+ $offset ||= 0;
+
+ my $filters = {usr => $user_id};
+ $$filters{event_time} = {'>' => $maxage} if ($maxage);
+
+ return new_editor(personality=>'open-ils.pcrud', authtoken=>$ses)->search_actor_usr_activity([
+ $filters,
+ { flesh => 1,
+ flesh_fields => {auact => ['etype']},
+ limit => $limit,
+ offset => $offset,
+ order_by => [{class => auact => field => event_time => direction => $sort}]
+ }
+ ]);
+
+}
+
+1;
diff --git a/Open-ILS/src/sql/Pg/005.schema.actors.sql b/Open-ILS/src/sql/Pg/005.schema.actors.sql
index 5ddf319ccc..5e68ae5ef3 100644
--- a/Open-ILS/src/sql/Pg/005.schema.actors.sql
+++ b/Open-ILS/src/sql/Pg/005.schema.actors.sql
@@ -1093,45 +1093,6 @@ BEGIN
END;
$$ LANGUAGE PLPGSQL;
-CREATE OR REPLACE FUNCTION
- actor.verify_passwd(pw_usr INTEGER, pw_type TEXT, test_passwd TEXT)
- RETURNS BOOLEAN AS $$
-DECLARE
- pw_salt TEXT;
-BEGIN
- /* Returns TRUE if the password provided matches the in-db password.
- * If the password type is salted, we compare the output of CRYPT().
- * NOTE: test_passwd is MD5(salt || MD5(password)) for legacy
- * 'main' passwords.
- */
-
- SELECT INTO pw_salt salt FROM actor.passwd
- WHERE usr = pw_usr AND passwd_type = pw_type;
-
- IF NOT FOUND THEN
- -- no such password
- RETURN FALSE;
- END IF;
-
- IF pw_salt IS NULL THEN
- -- Password is unsalted, compare the un-CRYPT'ed values.
- RETURN EXISTS (
- SELECT TRUE FROM actor.passwd WHERE
- usr = pw_usr AND
- passwd_type = pw_type AND
- passwd = test_passwd
- );
- END IF;
-
- RETURN EXISTS (
- SELECT TRUE FROM actor.passwd WHERE
- usr = pw_usr AND
- passwd_type = pw_type AND
- passwd = CRYPT(test_passwd, pw_salt)
- );
-END;
-$$ STRICT LANGUAGE PLPGSQL;
-
-- Remove all activity entries by activity type,
-- except the most recent entry per user.
CREATE OR REPLACE FUNCTION
diff --git a/Open-ILS/src/sql/Pg/610.schema.openapi.sql b/Open-ILS/src/sql/Pg/610.schema.openapi.sql
new file mode 100755
index 0000000000..902ca6617e
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/610.schema.openapi.sql
@@ -0,0 +1,446 @@
+BEGIN;
+
+DROP SCHEMA IF EXISTS openapi CASCADE;
+CREATE SCHEMA IF NOT EXISTS openapi;
+
+CREATE TABLE IF NOT EXISTS openapi.integrator (
+ id INT PRIMARY KEY REFERENCES actor.usr (id),
+ enabled BOOL NOT NULL DEFAULT TRUE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.json_schema_datatype (
+ name TEXT PRIMARY KEY,
+ label TEXT NOT NULL UNIQUE,
+ description TEXT
+);
+
+CREATE TABLE IF NOT EXISTS openapi.json_schema_format (
+ name TEXT PRIMARY KEY,
+ label TEXT NOT NULL UNIQUE,
+ description TEXT
+);
+
+CREATE TABLE IF NOT EXISTS openapi.rate_limit_definition (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL, -- i18n
+ limit_interval INTERVAL NOT NULL,
+ limit_count INT NOT NULL
+);
+SELECT SETVAL('openapi.rate_limit_definition_id_seq'::TEXT, 100);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint (
+ operation_id TEXT PRIMARY KEY,
+ path TEXT NOT NULL,
+ http_method TEXT NOT NULL CHECK (http_method IN ('get','put','post','delete','patch')),
+ security TEXT NOT NULL DEFAULT 'bearerAuth' CHECK (security IN ('bearerAuth','basicAuth','cookieAuth','paramAuth')),
+ summary TEXT NOT NULL,
+ method_source TEXT NOT NULL, -- perl module or opensrf application, tested by regex and assumes opensrf app name contains a "."
+ method_name TEXT NOT NULL,
+ method_params TEXT, -- eg, 'eg_auth_token hold' or 'eg_auth_token eg_user_id circ req.json'
+ active BOOL NOT NULL DEFAULT TRUE,
+ rate_limit INT REFERENCES openapi.rate_limit_definition (id),
+ CONSTRAINT path_and_method_once UNIQUE (path, http_method)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_param (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ name TEXT NOT NULL CHECK (name ~ '^\w+$'),
+ required BOOL NOT NULL DEFAULT FALSE,
+ in_part TEXT NOT NULL DEFAULT 'query' CHECK (in_part IN ('path','query','header','cookie')),
+ fm_type TEXT,
+ schema_type TEXT REFERENCES openapi.json_schema_datatype (name),
+ schema_format TEXT REFERENCES openapi.json_schema_format (name),
+ array_items TEXT REFERENCES openapi.json_schema_datatype (name),
+ default_value TEXT,
+ CONSTRAINT endpoint_and_name_once UNIQUE (endpoint, name),
+ CONSTRAINT format_requires_type CHECK (schema_format IS NULL OR schema_type IS NOT NULL),
+ CONSTRAINT array_items_requires_array_type CHECK (array_items IS NULL OR schema_type = 'array')
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_response (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ validate BOOL NOT NULL DEFAULT TRUE,
+ status INT NOT NULL DEFAULT 200,
+ content_type TEXT NOT NULL DEFAULT 'application/json',
+ description TEXT NOT NULL DEFAULT 'Success',
+ fm_type TEXT,
+ schema_type TEXT REFERENCES openapi.json_schema_datatype (name),
+ schema_format TEXT REFERENCES openapi.json_schema_format (name),
+ array_items TEXT REFERENCES openapi.json_schema_datatype (name),
+ CONSTRAINT endpoint_status_content_type_once UNIQUE (endpoint, status, content_type)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.perm_set (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL
+); -- push sequence value
+SELECT SETVAL('openapi.perm_set_id_seq'::TEXT, 1001);
+
+CREATE TABLE IF NOT EXISTS openapi.perm_set_perm_map (
+ id SERIAL PRIMARY KEY,
+ perm_set INT NOT NULL REFERENCES openapi.perm_set (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm INT NOT NULL REFERENCES permission.perm_list (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_perm_set_map (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint(operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm_set INT NOT NULL REFERENCES openapi.perm_set (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set (
+ name TEXT PRIMARY KEY,
+ description TEXT NOT NULL,
+ active BOOL NOT NULL DEFAULT TRUE,
+ rate_limit INT REFERENCES openapi.rate_limit_definition (id)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_user_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ accessor INT NOT NULL REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_accessor_once UNIQUE (accessor, endpoint)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_user_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ accessor INT NOT NULL REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_set_accessor_once UNIQUE (accessor, endpoint_set)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_ip_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ ip_range INET NOT NULL,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_ip_range_once UNIQUE (ip_range, endpoint)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_ip_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ ip_range INET NOT NULL,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_set_ip_range_once UNIQUE (ip_range, endpoint_set)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_endpoint_map (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_set_endpoint_once UNIQUE (endpoint_set, endpoint)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_perm_map (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm INT NOT NULL REFERENCES permission.perm_list (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_perm_set_map (
+ id SERIAL PRIMARY KEY,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm_set INT NOT NULL REFERENCES openapi.perm_set (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_perm_map (
+ id SERIAL PRIMARY KEY,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm INT NOT NULL REFERENCES permission.perm_list (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.authen_attempt_log (
+ request_id TEXT PRIMARY KEY,
+ attempt_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ ip_addr INET,
+ cred_user TEXT,
+ token TEXT
+);
+CREATE INDEX authen_cred_user_attempt_time_idx ON openapi.authen_attempt_log (attempt_time, cred_user);
+CREATE INDEX authen_ip_addr_attempt_time_idx ON openapi.authen_attempt_log (attempt_time, ip_addr);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_access_attempt_log (
+ request_id TEXT PRIMARY KEY,
+ attempt_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ allowed BOOL NOT NULL,
+ ip_addr INET,
+ accessor INT REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ token TEXT
+);
+CREATE INDEX access_accessor_attempt_time_idx ON openapi.endpoint_access_attempt_log (accessor, attempt_time);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_dispatch_log (
+ request_id TEXT PRIMARY KEY,
+ complete_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ error BOOL NOT NULL
+);
+
+CREATE OR REPLACE FUNCTION openapi.find_default_endpoint_rate_limit (target_endpoint TEXT) RETURNS openapi.rate_limit_definition AS $f$
+DECLARE
+ def_rl openapi.rate_limit_definition%ROWTYPE;
+BEGIN
+ -- Default rate limits can be applied at the endpoint or endpoint_set level;
+ -- endpoint overrides endpoint_set, and we choose the most restrictive from
+ -- the set if we have to look there.
+ SELECT d.* INTO def_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint e ON (e.rate_limit = d.id)
+ WHERE e.operation_id = target_endpoint;
+
+ IF NOT FOUND THEN
+ SELECT d.* INTO def_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_set es ON (es.rate_limit = d.id)
+ JOIN openapi.endpoint_set_endpoint_map m ON (es.name = m.endpoint_set AND m.endpoint = target_endpoint)
+ -- This ORDER BY calculates the avg time between requests the user would have to wait to perfectly
+ -- avoid rate limiting. So, a bigger wait means it's more restrictive. We take the most restrictive
+ -- set-applied one.
+ ORDER BY EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+ END IF;
+
+ -- If there's no default for the endpoint or set, we provide 1/sec.
+ IF NOT FOUND THEN
+ def_rl.limit_interval := '1 second'::INTERVAL;
+ def_rl.limit_count := 1;
+ END IF;
+
+ RETURN def_rl;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.find_user_endpoint_rate_limit (target_endpoint TEXT, accessing_usr INT) RETURNS openapi.rate_limit_definition AS $f$
+DECLARE
+ def_u_rl openapi.rate_limit_definition%ROWTYPE;
+BEGIN
+ SELECT d.* INTO def_u_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_user_rate_limit_map e ON (e.rate_limit = d.id)
+ WHERE e.endpoint = target_endpoint
+ AND e.accessor = accessing_usr;
+
+ IF NOT FOUND THEN
+ SELECT d.* INTO def_u_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_set_user_rate_limit_map e ON (e.rate_limit = d.id AND e.accessor = accessing_usr)
+ JOIN openapi.endpoint_set_endpoint_map m ON (e.endpoint_set = m.endpoint_set AND m.endpoint = target_endpoint)
+ ORDER BY EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+ END IF;
+
+ RETURN def_u_rl;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.find_ip_addr_endpoint_rate_limit (target_endpoint TEXT, from_ip_addr INET) RETURNS openapi.rate_limit_definition AS $f$
+DECLARE
+ def_i_rl openapi.rate_limit_definition%ROWTYPE;
+BEGIN
+ SELECT d.* INTO def_i_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_ip_rate_limit_map e ON (e.rate_limit = d.id)
+ WHERE e.endpoint = target_endpoint
+ AND e.ip_range && from_ip_addr
+ -- For IPs, we order first by the size of the ranges that we
+ -- matched (mask length), most specific (smallest block of IPs)
+ -- first, then by the restrictiveness of the limit, more restrictive first.
+ ORDER BY MASKLEN(e.ip_range) DESC, EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+
+ IF NOT FOUND THEN
+ SELECT d.* INTO def_i_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_set_ip_rate_limit_map e ON (e.rate_limit = d.id AND ip_range && from_ip_addr)
+ JOIN openapi.endpoint_set_endpoint_map m ON (e.endpoint_set = m.endpoint_set AND m.endpoint = target_endpoint)
+ ORDER BY MASKLEN(e.ip_range) DESC, EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+ END IF;
+
+ RETURN def_i_rl;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.check_generic_endpoint_rate_limit (target_endpoint TEXT, accessing_usr INT DEFAULT NULL, from_ip_addr INET DEFAULT NULL) RETURNS INT AS $f$
+DECLARE
+ def_rl openapi.rate_limit_definition%ROWTYPE;
+ def_u_rl openapi.rate_limit_definition%ROWTYPE;
+ def_i_rl openapi.rate_limit_definition%ROWTYPE;
+ u_wait INT;
+ i_wait INT;
+BEGIN
+ def_rl := openapi.find_default_endpoint_rate_limit(target_endpoint);
+
+ IF accessing_usr IS NOT NULL THEN
+ def_u_rl := openapi.find_user_endpoint_rate_limit(target_endpoint, accessing_usr);
+ END IF;
+
+ IF from_ip_addr IS NOT NULL THEN
+ def_i_rl := openapi.find_ip_addr_endpoint_rate_limit(target_endpoint, from_ip_addr);
+ END IF;
+
+ -- Now we test the user-based and IP-based limits in their focused way...
+ IF def_u_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.accessor ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.accessor = accessing_usr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ IF def_i_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ -- If there are no user-specific or IP-based limit
+ -- overrides; check endpoint-wide limits for user,
+ -- then IP, and if we were passed neither, then limit
+ -- endpoint access for all users. Better to lock it
+ -- all down than to set the servers on fire.
+ IF COALESCE(u_wait, i_wait) IS NULL AND COALESCE(def_i_rl.id, def_u_rl.id) IS NULL THEN
+ IF accessing_usr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.accessor ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.accessor = accessing_usr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSIF from_ip_addr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSE -- we have no user and no IP, global per-endpoint rate limit?
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.endpoint ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ END IF;
+ END IF;
+
+ -- Send back the largest required wait time, or NULL for no restriction
+ u_wait := GREATEST(u_wait,i_wait);
+ IF u_wait > 0 THEN
+ RETURN u_wait;
+ END IF;
+
+ RETURN NULL;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.check_auth_endpoint_rate_limit (accessing_usr TEXT DEFAULT NULL, from_ip_addr INET DEFAULT NULL) RETURNS INT AS $f$
+DECLARE
+ def_rl openapi.rate_limit_definition%ROWTYPE;
+ def_u_rl openapi.rate_limit_definition%ROWTYPE;
+ def_i_rl openapi.rate_limit_definition%ROWTYPE;
+ u_wait INT;
+ i_wait INT;
+BEGIN
+ def_rl := openapi.find_default_endpoint_rate_limit('authenticateUser');
+
+ IF accessing_usr IS NOT NULL THEN
+ SELECT (openapi.find_user_endpoint_rate_limit('authenticateUser', u.id)).* INTO def_u_rl
+ FROM actor.usr u
+ WHERE u.usrname = accessing_usr;
+ END IF;
+
+ IF from_ip_addr IS NOT NULL THEN
+ def_i_rl := openapi.find_ip_addr_endpoint_rate_limit('authenticateUser', from_ip_addr);
+ END IF;
+
+ -- Now we test the user-based and IP-based limits in their focused way...
+ IF def_u_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.cred_user ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.cred_user = accessing_usr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ IF def_i_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ -- If there are no user-specific or IP-based limit
+ -- overrides; check endpoint-wide limits for user,
+ -- then IP, and if we were passed neither, then limit
+ -- endpoint access for all users. Better to lock it
+ -- all down than to set the servers on fire.
+ IF COALESCE(u_wait, i_wait) IS NULL AND COALESCE(def_i_rl.id, def_u_rl.id) IS NULL THEN
+ IF accessing_usr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.cred_user ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.cred_user = accessing_usr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSIF from_ip_addr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSE -- we have no user and no IP, global auth attempt rate limit?
+ SELECT CEIL(EXTRACT(EPOCH FROM (l.attempt_time + def_rl.limit_interval) - NOW())) INTO u_wait
+ FROM openapi.authen_attempt_log l
+ WHERE l.attempt_time > NOW() - def_rl.limit_interval
+ ORDER BY l.attempt_time DESC
+ LIMIT 1 OFFSET def_rl.limit_count;
+ END IF;
+ END IF;
+
+ -- Send back the largest required wait time, or NULL for no restriction
+ u_wait := GREATEST(u_wait,i_wait);
+ IF u_wait > 0 THEN
+ RETURN u_wait;
+ END IF;
+
+ RETURN NULL;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+COMMIT;
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index c36d191c51..8f34ce9ced 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -2047,6 +2047,20 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
'Allow setting and unsetting hold from top of hold queue (cut in line)', 'ppl', 'description'))
;
+INSERT INTO permission.perm_list (id,code) VALUES
+ (677,'ADMIN_OPENAPI'),
+ (678,'API_LOGIN'),
+ (679,'REST.api'),
+ (680,'REST.api.patrons'),
+ (681,'REST.api.orgs'),
+ (682,'REST.api.bibs'),
+ (683,'REST.api.items'),
+ (684,'REST.api.holds'),
+ (685,'REST.api.collections'),
+ (686,'REST.api.courses'),
+ (687,'group_application.api_integrator')
+ON CONFLICT DO NOTHING;
+
SELECT SETVAL('permission.perm_list_id_seq'::TEXT, 1000);
INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
@@ -2083,6 +2097,22 @@ INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, u
(14, oils_i18n_gettext(14, 'Data Review', 'pgt', 'name'), 3, NULL, '3 years', TRUE, 'group_application.user.staff.data_review');
INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
(15, oils_i18n_gettext(15, 'Volunteers', 'pgt', 'name'), 3, NULL, '3 years', TRUE, 'group_application.user.staff.volunteers');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (16, oils_i18n_gettext(16, 'API Integrator', 'pgt', 'name'), 1, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (17, oils_i18n_gettext(17, 'Patron API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (18, oils_i18n_gettext(18, 'Org Unit API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (19, oils_i18n_gettext(19, 'Bib Record API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (20, oils_i18n_gettext(20, 'Item Record API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (21, oils_i18n_gettext(21, 'Holds API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (22, oils_i18n_gettext(22, 'Debt Collection API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
+INSERT INTO permission.grp_tree (id, name, parent, description, perm_interval, usergroup, application_perm) VALUES
+ (23, oils_i18n_gettext(23, 'Course Reserves API', 'pgt', 'name'), 16, NULL, '3 years', TRUE, 'group_application.api_integrator');
SELECT SETVAL('permission.grp_tree_id_seq'::TEXT, (SELECT MAX(id) FROM permission.grp_tree));
@@ -2098,7 +2128,7 @@ INSERT INTO permission.grp_penalty_threshold (grp,org_unit,penalty,threshold)
SELECT SETVAL('permission.grp_penalty_threshold_id_seq'::TEXT, (SELECT MAX(id) FROM permission.grp_penalty_threshold));
--- Add basic user permissions to the Users group
+-- Add basic user permissions to the Staff and Patrons groups
INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
SELECT
@@ -2108,7 +2138,7 @@ INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
permission.perm_list perm,
actor.org_unit_type aout
WHERE
- pgt.name = 'Users' AND
+ pgt.name IN ('Staff','Patrons') AND
aout.name = 'Consortium' AND
perm.code IN (
'COPY_CHECKIN',
@@ -2122,6 +2152,25 @@ INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
'user_request.create'
);
+-- Add baselin API Integrator permissions
+
+INSERT INTO permission.grp_perm_map (grp,perm,depth)
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='API Integrator' AND p.code IN ('API_LOGIN','REST.api')
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Patron API' AND p.code = 'REST.api.patrons'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Org Unit API' AND p.code = 'REST.api.orgs'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Bib Record API' AND p.code = 'REST.api.bibs'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Item Record API' AND p.code = 'REST.api.items'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Holds API' AND p.code = 'REST.api.holds'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Debt Collection API' AND p.code = 'REST.api.collections'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Course Reserves API' AND p.code = 'REST.api.courses'
+ON CONFLICT DO NOTHING;
-- Add basic user permissions to the Data Review group
@@ -25064,3 +25113,905 @@ VALUES (
'bool'
);
+------- OpenAPI supporting data ------
+
+INSERT INTO actor.passwd_type (code, name, login, crypt_algo, iter_count)
+ VALUES ('api', 'OpenAPI Integration Password', TRUE, 'bf', 10)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.json_schema_datatype (name,label) VALUES
+ ('boolean','Boolean'),
+ ('string','String'),
+ ('integer','Integer'),
+ ('number','Number'),
+ ('array','Array'),
+ ('object','Object')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.json_schema_format (name,label) VALUES
+ ('date-time','Timestamp'),
+ ('date','Date'),
+ ('time','Time'),
+ ('interval','Interval'),
+ ('email','Email Address'),
+ ('uri','URI'),
+ ('identifier','Opaque Identifier'),
+ ('money','Money'),
+ ('float','Floating Point Number'),
+ ('int64','Large Integer'),
+ ('password','Password')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.rate_limit_definition (id, name, limit_interval, limit_count) VALUES
+ (1, 'Once per second', '1 second', 1),
+ (2, 'Ten per minute', '1 minute', 10),
+ (3, 'One hunderd per hour', '1 hour', 100),
+ (4, 'One thousand per hour', '1 hour', 1000),
+ (5, 'One thousand per 24 hour period', '24 hours', 1000),
+ (6, 'Ten thousand per 24 hour period', '24 hours', 10000),
+ (7, 'Unlimited', '1 second', 1000000),
+ (8, 'One hundred per second', '1 second', 100)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.perm_set (id, name) VALUES
+ (1,'Self - API only'),
+ (2,'Patrons - API only'),
+ (3,'Orgs - API only'),
+ (4,'Bibs - API only'),
+ (5,'Items - API only'),
+ (6,'Holds - API only'),
+ (7,'Collections - API only'),
+ (8,'Courses - API only'),
+ (101,'Self - standard permissions'),
+ (102,'Patrons - standard permissions'),
+ (103,'Orgs - standard permissions'),
+ (104,'Bibs - standard permissions'),
+ (105,'Items - standard permissions'),
+ (106,'Holds - standard permissions'),
+ (107,'Collections - standard permissions'),
+ (108,'Courses - standard permissions')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.perm_set_perm_map (perm_set, perm)
+ SELECT 1, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api')
+ UNION
+ SELECT 2, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.patrons')
+ UNION
+ SELECT 3, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.orgs')
+ UNION
+ SELECT 4, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.bibs')
+ UNION
+ SELECT 5, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.items')
+ UNION
+ SELECT 6, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.holds')
+ UNION
+ SELECT 7, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.collections')
+ UNION
+ SELECT 8, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.cources')
+ UNION
+ SELECT 101, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 102, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN','VIEW_USER')
+ UNION
+ SELECT 103, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 104, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 105, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 106, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN','VIEW_USER')
+ UNION
+ SELECT 107, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN','VIEW_USER')
+ UNION
+ SELECT 108, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint_set (name, description) VALUES
+ ('self', 'Methods for retrieving and manipulating your own user account information'),
+ ('orgs', 'Methods for retrieving and manipulating organizational unit information'),
+ ('patrons', 'Methods for retrieving and manipulating patron information'),
+ ('holds', 'Methods for accessing and manipulating hold data'),
+ ('collections', 'Methods for accessing and manipulating patron debt collections data'),
+ ('bibs', 'Methods for accessing and manipulating bibliographic records and related data'),
+ ('items', 'Methods for accessing and manipulating barcoded item records'),
+ ('courses', 'Methods for accessing and manipulating course reserve data')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint_set_perm_set_map (endpoint_set, perm_set) VALUES
+ ('self', 1), ('self', 101),
+ ('patrons', 2), ('patrons', 102),
+ ('orgs', 3), ('orgs', 103),
+ ('bibs', 4), ('bibs', 104),
+ ('items', 5), ('items', 105),
+ ('holds', 6), ('holds', 106),
+ ('collections', 7), ('collections', 107),
+ ('courses', 8), ('courses', 108)
+ON CONFLICT DO NOTHING;
+
+
+----------- OpenAPI endpoint configuration -----------------
+
+-- ===== authentication
+INSERT INTO openapi.endpoint (operation_id, path, security, http_method, summary, method_source, method_name, method_params, rate_limit) VALUES
+ ('authenticateUser', '/self/auth', 'basicAuth', 'get', 'Authenticate API user', 'OpenILS::OpenAPI::Controller', 'authenticateUser', 'param.u param.p param.t', 8),
+ ('logoutUser', '/self/auth', 'bearerAuth', 'delete', 'Logout API user', 'open-ils.auth', 'open-ils.auth.session.delete', 'eg_auth_token', 8)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value) VALUES
+ ('authenticateUser','u','query','string',NULL,NULL),
+ ('authenticateUser','p','query','string','password',NULL),
+ ('authenticateUser','t','query','string',NULL,'api')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('authenticateUser','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('authenticateUser','text/plain') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('logoutUser','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('logoutUser','text/plain') ON CONFLICT DO NOTHING;
+
+-- ===== self-service
+-- get/update me
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfProfile', '/self/me', 'get', 'Return patron/user record for logged in user', 'OpenILS::OpenAPI::Controller::patron', 'deliver_user', 'eg_auth_token eg_user_id'),
+ ('selfUpdateParts', '/self/me', 'patch', 'Update portions of the logged in user''s record', 'OpenILS::OpenAPI::Controller::patron', 'update_user_parts', 'eg_auth_token req.json')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveSelfProfile','au') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('selfUpdateParts','object') ON CONFLICT DO NOTHING;
+
+-- get my standing penalties
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'selfActivePenalties',
+ '/self/standing_penalties',
+ 'get',
+ 'Produces patron-visible penalty details for the authorized account',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token eg_user_id "1"'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('selfActivePenalties','array','object') ON CONFLICT DO NOTHING; -- array of fleshed ausp
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'selfPenalty',
+ '/self/standing_penalty/:penaltyid',
+ 'get',
+ 'Retrieve one penalty for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token eg_user_id "1" param.penaltyid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('selfPenalty','penaltyid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+-- manage my holds
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfHolds', '/self/holds', 'get', 'Return unfilled holds for the authorized account', 'OpenILS::OpenAPI::Controller::hold', 'open_holds', 'eg_auth_token eg_user_id'),
+ ('requestSelfHold', '/self/holds', 'post', 'Request a hold for the authorized account', 'OpenILS::OpenAPI::Controller::hold', 'request_hold', 'eg_auth_token eg_user_id req.json'),
+ ('retrieveSelfHold', '/self/hold/:hold', 'get', 'Retrieve one hold for the logged in user', 'OpenILS::OpenAPI::Controller::hold', 'fetch_user_hold', 'eg_auth_token eg_user_id param.hold'),
+ ('updateSelfHold', '/self/hold/:hold', 'patch', 'Update one hold for the logged in user', 'OpenILS::OpenAPI::Controller::hold', 'update_user_hold', 'eg_auth_token eg_user_id param.hold req.json'),
+ ('cancelSelfHold', '/self/hold/:hold', 'delete', 'Cancel one hold for the logged in user', 'OpenILS::OpenAPI::Controller::hold', 'cancel_user_hold', 'eg_auth_token eg_user_id param.hold "6"')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrieveSelfHold','hold','path','integer',TRUE),
+ ('updateSelfHold','hold','path','integer',TRUE),
+ ('cancelSelfHold','hold','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfHolds','array','object') ON CONFLICT DO NOTHING;
+
+-- general xact list
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveSelfXacts',
+ '/self/transactions/:state',
+ 'get',
+ 'Produces a list of transactions of the logged in user',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'transactions_by_state',
+ 'eg_auth_token eg_user_id state param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('retrieveSelfXacts','state','path','string',NULL,NULL,TRUE),
+ ('retrieveSelfXacts','limit','query','integer',NULL,NULL,FALSE),
+ ('retrieveSelfXacts','offset','query','integer',NULL,'0',FALSE),
+ ('retrieveSelfXacts','sort','query','string',NULL,'desc',FALSE),
+ ('retrieveSelfXacts','before','query','string','date-time',NULL,FALSE),
+ ('retrieveSelfXacts','after','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfXacts','array','object') ON CONFLICT DO NOTHING;
+
+-- general xact detail
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveSelfXact',
+ '/self/transaction/:id',
+ 'get',
+ 'Details of one transaction for the logged in user',
+ 'open-ils.actor',
+ 'open-ils.actor.user.transaction.fleshed.retrieve',
+ 'eg_auth_token param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveSelfXact','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfCircs', '/self/checkouts', 'get', 'Open Circs for the logged in user', 'open-ils.circ', 'open-ils.circ.actor.user.checked_out.atomic', 'eg_auth_token eg_user_id'),
+ ('requestSelfCirc', '/self/checkouts', 'post', 'Attempt a circulation for the logged in user', 'OpenILS::OpenAPI::Controller::patron', 'checkout_item', 'eg_auth_token eg_user_id req.json')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfCircs','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveSelfCircHistory',
+ '/self/checkouts/history',
+ 'get',
+ 'Historical Circs for logged in user',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'circulation_history',
+ 'eg_auth_token eg_user_id param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value) VALUES
+ ('retrieveSelfCircHistory','limit','query','integer',NULL,NULL),
+ ('retrieveSelfCircHistory','offset','query','integer',NULL,'0'),
+ ('retrieveSelfCircHistory','sort','query','string',NULL,'desc'),
+ ('retrieveSelfCircHistory','before','query','string','date-time',NULL),
+ ('retrieveSelfCircHistory','after','query','string','date-time',NULL)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfCircHistory','array','object') ON CONFLICT DO NOTHING;
+
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfCirc', '/self/checkout/:id', 'get', 'Retrieve one circulation for the logged in user', 'open-ils.actor', 'open-ils.actor.user.transaction.fleshed.retrieve', 'eg_auth_token param.id'),
+ ('renewSelfCirc', '/self/checkout/:id', 'put', 'Renew one circulation for the logged in user', 'OpenILS::OpenAPI::Controller::patron', 'renew_circ', 'eg_auth_token param.id eg_user_id'),
+ ('checkinSelfCirc', '/self/checkout/:id', 'delete', 'Check in one circulation for the logged in user', 'OpenILS::OpenAPI::Controller::patron', 'checkin_circ', 'eg_auth_token param.id eg_user_id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrieveSelfCirc','id','path','integer',TRUE),
+ ('renewSelfCirc','id','path','integer',TRUE),
+ ('checkinSelfCirc','id','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+
+-- bib, item, and org methods
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOrgList',
+ '/org_units',
+ 'get',
+ 'List of org units',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'flat_org_list',
+ 'every_param.field every_param.comparison every_param.value'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES
+ ('retrieveOrgList','field','query','string'),
+ ('retrieveOrgList','comparison','query','string'),
+ ('retrieveOrgList','value','query','string')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveOrgList','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOneOrg',
+ '/org_unit/:id',
+ 'get',
+ 'One org unit',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'one_org',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveOneOrg','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveOneOrg','aou') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name) VALUES (
+ 'retrieveFullOrgTree',
+ '/org_tree',
+ 'get',
+ 'Full hierarchical tree of org units',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'full_tree'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveFullOrgTree','aou') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePartialOrgTree',
+ '/org_tree/:id',
+ 'get',
+ 'Partial hierarchical tree of org units starting from a specific org unit',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'one_tree',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePartialOrgTree','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrievePartialOrgTree','aou') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'createOneBib',
+ '/bibs',
+ 'post',
+ 'Attempts to create a bibliographic record using MARCXML passed as the request content',
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record.xml.create',
+ 'eg_auth_token req.text param.sourcename'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('createOneBib','sourcename','query','string') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('createOneBib','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'updateOneBib',
+ '/bib/:id',
+ 'put',
+ 'Attempts to update a bibliographic record using MARCXML passed as the request content',
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record.marc.replace',
+ 'eg_auth_token param.id req.text param.sourcename'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('updateOneBib','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('updateOneBib','sourcename','query','string') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('updateOneBib','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'deleteOneBib',
+ '/bib/:id',
+ 'delete',
+ 'Attempts to delete a bibliographic record',
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record_entry.delete',
+ 'eg_auth_token param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('deleteOneBib','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,validate) VALUES ('deleteOneBib','integer',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'updateBREParts',
+ '/bib/:id',
+ 'patch',
+ 'Attempts to update the biblio.record_entry metadata surrounding a bib record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'update_bre_parts',
+ 'eg_auth_token param.id req.json'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('updateBREParts','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('updateBREParts','bre') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOneBib',
+ '/bib/:id',
+ 'get',
+ 'Retrieve a bibliographic record, either full biblio::record_entry object, or just the MARCXML',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_one_bib',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveOneBib','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveOneBib','bre') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('retrieveOneBib','application/xml') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('retrieveOneBib','application/octet-stream') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOneBibHoldings',
+ '/bib/:id/holdings',
+ 'get',
+ 'Retrieve the holdings data for a bibliographic record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_one_bib_holdings',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,default_value,required) VALUES
+ ('retrieveOneBibHoldings','id','path','integer',NULL,TRUE),
+ ('retrieveOneBibHoldings','limit','query','integer',NULL,FALSE),
+ ('retrieveOneBibHoldings','offset','query','integer','0',FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveOneBibHoldings','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'bibDisplayFields',
+ '/bib/:id/display_fields',
+ 'get',
+ 'Retrieve display-related data for a bibliographic record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_one_bib_display_fields',
+ 'param.id req.text'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('bibDisplayFields','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('bibDisplayFields','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'newItems',
+ '/items/fresh',
+ 'get',
+ 'Retrieve a list of newly added items',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_new_items',
+ 'param.limit param.offset param.maxage'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,default_value) VALUES ('newItems','limit','query','integer','0') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,default_value) VALUES ('newItems','offset','query','integer','100') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format) VALUES ('newItems','maxage','query','string','interval') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('newItems','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveItem',
+ '/item/:barcode',
+ 'get',
+ 'Retrieve one item by its barcode',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'item_by_barcode',
+ 'param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveItem','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status,schema_type) VALUES ('retrieveItem','Item Lookup Failed','404','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveItem','acp') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'createItem',
+ '/items',
+ 'post',
+ 'Create an item record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'create_or_update_one_item',
+ 'eg_auth_token req.json'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status) VALUES ('createItem','Item Creation Failed','400') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('createItem','acp') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'updateItem',
+ '/item/:barcode',
+ 'patch',
+ 'Update a restricted set of item record fields',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'create_or_update_one_item',
+ 'eg_auth_token req.json param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('updateItem','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status) VALUES ('updateItem','Item Update Failed','400') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('updateItem','acp') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'deleteItem',
+ '/item/:barcode',
+ 'delete',
+ 'Delete one item record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'delete_one_item',
+ 'eg_auth_token param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('deleteItem','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status,schema_type) VALUES ('deleteItem','Item Deletion Failed','404','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('deleteItem','boolean') ON CONFLICT DO NOTHING;
+
+-- === patron (non-self) methods
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'searchPatrons',
+ '/patrons',
+ 'get',
+ 'List of patrons matching requested conditions',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'find_users',
+ 'eg_auth_token every_param.field every_param.comparison every_param.value param.limit param.offset'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES
+ ('searchPatrons','field','query','string'),
+ ('searchPatrons','comparison','query','string'),
+ ('searchPatrons','value','query','string')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value) VALUES
+ ('searchPatrons','offset','query','integer',NULL,'0'),
+ ('searchPatrons','limit','query','integer',NULL,'100')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('searchPatrons','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'verifyUserCredentials',
+ '/patrons/verify',
+ 'get',
+ 'Verify the credentials for a user account',
+ 'open-ils.actor',
+ 'open-ils.actor.verify_user_password',
+ 'eg_auth_token param.barcode param.usrname "" param.password'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('verifyUserCredentials','barcode','query','string',NULL,FALSE),
+ ('verifyUserCredentials','usrname','query','string',NULL,FALSE),
+ ('verifyUserCredentials','password','query','string','password',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('verifyUserCredentials','boolean') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronProfile', '/patron/:userid', 'get', 'Return patron/user record for the requested user', 'OpenILS::OpenAPI::Controller::patron', 'deliver_user', 'eg_auth_token param.userid')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronProfile','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrievePatronProfile','au') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronIdByCardBarcode',
+ '/patrons/by_barcode/:barcode/id',
+ 'get',
+ 'Retrieve patron id by barcode',
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+ 'eg_auth_token param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronIdByCardBarcode','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('patronIdByCardBarcode','integer') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronIdByUsername',
+ '/patrons/by_username/:username/id',
+ 'get',
+ 'Retrieve patron id by username',
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+ 'eg_auth_token "" param.username'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronIdByUsername','username','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('patronIdByUsername','integer') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronByCardBarcode',
+ '/patrons/by_barcode/:barcode',
+ 'get',
+ 'Retrieve patron by barcode',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'user_by_identifier_string',
+ 'eg_auth_token param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronByCardBarcode','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronByCardBarcode','au') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronByUsername',
+ '/patrons/by_username/:username',
+ 'get',
+ 'Retrieve patron by username',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'user_by_identifier_string',
+ 'eg_auth_token "" param.username'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronByUsername','username','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronByUsername','au') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePatronCircHistory',
+ '/patron/:userid/checkouts/history',
+ 'get',
+ 'Historical Circs for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'circulation_history',
+ 'eg_auth_token param.userid param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('retrievePatronCircHistory','userid','path','integer',NULL,NULL,TRUE),
+ ('retrievePatronCircHistory','limit','query','integer',NULL,NULL,FALSE),
+ ('retrievePatronCircHistory','offset','query','integer',NULL,'0',FALSE),
+ ('retrievePatronCircHistory','sort','query','string',NULL,'desc',FALSE),
+ ('retrievePatronCircHistory','before','query','string','date-time',NULL,FALSE),
+ ('retrievePatronCircHistory','after','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrievePatronCircHistory','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronHolds', '/patron/:userid/holds', 'get', 'Retrieve unfilled holds for a patron', 'OpenILS::OpenAPI::Controller::hold', 'open_holds', 'eg_auth_token param.userid'),
+ ('requestPatronHold', '/patron/:userid/holds', 'post', 'Request a hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'request_hold', 'eg_auth_token param.userid req.json'),
+ ('retrievePatronHold','/patron/:userid/hold/:hold', 'get', 'Retrieve one hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'fetch_user_hold', 'eg_auth_token param.userid param.hold'),
+ ('updatePatronHold', '/patron/:userid/hold/:hold', 'patch', 'Update one hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'update_user_hold', 'eg_auth_token param.userid param.hold req.json'),
+ ('cancelPatronHold', '/patron/:userid/hold/:hold', 'delete', 'Cancel one hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'cancel_user_hold', 'eg_auth_token param.userid param.hold "6"')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrievePatronHolds','userid','path','integer',TRUE),
+ ('retrievePatronHold','userid','path','integer',TRUE),
+ ('retrievePatronHold','hold','path','integer',TRUE),
+ ('requestPatronHold','userid','path','integer',TRUE),
+ ('updatePatronHold','userid','path','integer',TRUE),
+ ('updatePatronHold','hold','path','integer',TRUE),
+ ('cancelPatronHold','userid','path','integer',TRUE),
+ ('cancelPatronHold','hold','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveHoldPickupLocations', '/holds/pickup_locations', 'get', 'Retrieve all valid hold/reserve pickup locations', 'OpenILS::OpenAPI::Controller::hold', 'valid_hold_pickup_locations', '')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveHoldPickupLocations','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveHold', '/hold/:hold', 'get', 'Retrieve one hold object', 'open-ils.circ', 'open-ils.circ.hold.details.retrieve', 'eg_auth_token param.hold')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveHold','hold','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePatronXacts',
+ '/patron/:userid/transactions/:state',
+ 'get',
+ 'Produces a list of transactions of the specified user',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'transactions_by_state',
+ 'eg_auth_token param.userid state param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('retrievePatronXacts','userid','path','integer',NULL,NULL,TRUE),
+ ('retrievePatronXacts','state','path','string',NULL,NULL,TRUE),
+ ('retrievePatronXacts','limit','query','integer',NULL,NULL,FALSE),
+ ('retrievePatronXacts','offset','query','integer',NULL,'0',FALSE),
+ ('retrievePatronXacts','sort','query','string',NULL,'desc',FALSE),
+ ('retrievePatronXacts','before','query','string','date-time',NULL,FALSE),
+ ('retrievePatronXacts','after','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrievePatronXacts','array','object') ON CONFLICT DO NOTHING;
+
+-- general xact detail
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePatronXact',
+ '/patron/:userid/transaction/:id',
+ 'get',
+ 'Details of one transaction for the specified user',
+ 'open-ils.actor',
+ 'open-ils.actor.user.transaction.fleshed.retrieve',
+ 'eg_auth_token param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronXact','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronXact','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronCircs', '/patron/:userid/checkouts', 'get', 'Open Circs for a patron', 'open-ils.circ', 'open-ils.circ.actor.user.checked_out.atomic', 'eg_auth_token param.userid'),
+ ('requestPatronCirc', '/patron/:userid/checkouts', 'post', 'Attempt a circulation for a patron', 'OpenILS::OpenAPI::Controller::patron', 'checkout_item', 'eg_auth_token param.userid req.json')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronCircs','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('requestPatronCirc','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrievePatronCircs','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronCirc', '/patron/:userid/checkout/:id', 'get', 'Retrieve one circulation for the specified user', 'open-ils.actor', 'open-ils.actor.user.transaction.fleshed.retrieve', 'eg_auth_token param.id'),
+ ('renewPatronCirc', '/patron/:userid/checkout/:id', 'put', 'Renew one circulation for the specified user', 'OpenILS::OpenAPI::Controller::patron', 'renew_circ', 'eg_auth_token param.id param.userid'),
+ ('checkinPatronCirc', '/patron/:userid/checkout/:id', 'delete', 'Check in one circulation for the specified user', 'OpenILS::OpenAPI::Controller::patron', 'checkin_circ', 'eg_auth_token param.id param.userid')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrievePatronCirc','userid','path','integer',TRUE),
+ ('retrievePatronCirc','id','path','integer',TRUE),
+ ('renewPatronCirc','userid','path','integer',TRUE),
+ ('renewPatronCirc','id','path','integer',TRUE),
+ ('checkinPatronCirc','userid','path','integer',TRUE),
+ ('checkinPatronCirc','id','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronATEvents',
+ '/patron/:userid/triggered_events',
+ 'get',
+ 'Retrieve a list of A/T events for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_at_events',
+ 'eg_auth_token param.userid param.limit param.offset param.before param.after every_param.hook'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('patronATEvents','userid','path','integer',NULL,NULL,TRUE),
+ ('patronATEvents','limit','query','integer',NULL,'100',FALSE),
+ ('patronATEvents','offset','query','integer',NULL,'0',FALSE),
+ ('patronATEvents','before','query','string','date-time',NULL,FALSE),
+ ('patronATEvents','after','query','string','date-time',NULL,FALSE),
+ ('patronATEvents','hook','query','string',NULL,NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronATEvents','array','integer') ON CONFLICT DO NOTHING; -- array of ausp ids
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronATEvent',
+ '/patron/:userid/triggered_event/:eventid',
+ 'get',
+ 'Retrieve one penalty for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_at_events',
+ 'eg_auth_token param.userid "1" "0" "" "" "" param.eventid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronATEvent','eventid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronATEvent','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronActivePenalties',
+ '/patron/:userid/standing_penalties',
+ 'get',
+ 'Retrieve all penalty details for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token param.userid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronActivePenalties','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronActivePenalties','array','integer') ON CONFLICT DO NOTHING; -- array of ausp ids
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronPenalty',
+ '/patron/:userid/standing_penalty/:penaltyid',
+ 'get',
+ 'Retrieve one penalty for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token param.userid "0" param.penaltyid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronPenalty','penaltyid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronPenalty','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronActiveMessages',
+ '/patron/:userid/messages',
+ 'get',
+ 'Retrieve all active message ids for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_messages',
+ 'eg_auth_token param.userid "0"'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronActiveMessages','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronActiveMessages','array','integer') ON CONFLICT DO NOTHING; -- array of aum ids
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronMessage',
+ '/patron/:userid/message/:msgid',
+ 'get',
+ 'Retrieve one message for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_messages',
+ 'eg_auth_token param.userid "0" param.msgid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessage','msgid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessage','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronMessage','aum') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronMessageUpdate',
+ '/patron/:userid/message/:msgid',
+ 'patch',
+ 'Update one message for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'update_usr_message',
+ 'eg_auth_token eg_user_id param.userid param.msgid req.json'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageUpdate','msgid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageUpdate','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronMessageUpdate','aum') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronMessageArchive',
+ '/patron/:userid/message/:msgid',
+ 'delete',
+ 'Archive one message for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'archive_usr_message',
+ 'eg_auth_token eg_user_id param.userid param.msgid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageArchive','msgid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageArchive','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('patronMessageArchive','boolean') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronActivityLog',
+ '/patron/:userid/activity',
+ 'get',
+ 'Retrieve patron activity (authen, authz, etc)',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_activity',
+ 'eg_auth_token param.userid param.maxage param.limit param.offset param.sort'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('patronActivityLog','userid','path','integer',NULL,NULL,TRUE),
+ ('patronActivityLog','limit','query','integer',NULL,'100',FALSE),
+ ('patronActivityLog','offset','query','integer',NULL,'0',FALSE),
+ ('patronActivityLog','sort','query','string',NULL,'desc',FALSE),
+ ('patronActivityLog','maxage','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronActivityLog','array','object') ON CONFLICT DO NOTHING;
+
+------- collections
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsPatronsOfInterest',
+ '/collections/:shortname/users_of_interest',
+ 'get',
+ 'List of patrons to consider for collections based on the search criteria provided.',
+ 'open-ils.collections',
+ 'open-ils.collections.users_of_interest.retrieve',
+ 'eg_auth_token param.fine_age param.fine_amount param.shortname'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsPatronsOfInterest','shortname','path','string',NULL,TRUE),
+ ('collectionsPatronsOfInterest','fine_age','query','integer',NULL,TRUE),
+ ('collectionsPatronsOfInterest','fine_amount','query','string','money',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items,validate) VALUES ('collectionsPatronsOfInterest','array','object',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsPatronsOfInterestWarning',
+ '/collections/:shortname/users_of_interest/warning',
+ 'get',
+ 'List of patrons with the PATRON_EXCEEDS_COLLECTIONS_WARNING penalty to consider for collections based on the search criteria provided.',
+ 'open-ils.collections',
+ 'open-ils.collections.users_of_interest.warning_penalty.retrieve',
+ 'eg_auth_token param.shortname param.min_age param.max_age'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsPatronsOfInterestWarning','shortname','path','string',NULL,TRUE),
+ ('collectionsPatronsOfInterestWarning','min_age','query','string','date-time',FALSE),
+ ('collectionsPatronsOfInterestWarning','max_age','query','string','date-time',FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items,validate) VALUES ('collectionsPatronsOfInterestWarning','array','object',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsGetPatronDetail',
+ '/patron/:usrid/collections/:shortname',
+ 'get',
+ 'Get collections-related transaction details for a patron.',
+ 'open-ils.collections',
+ 'open-ils.collections.user_transaction_details.retrieve',
+ 'eg_auth_token param.start param.end param.shortname every_param.usrid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsGetPatronDetail','usrid','path','integer',NULL,TRUE),
+ ('collectionsGetPatronDetail','shortname','path','string',NULL,TRUE),
+ ('collectionsGetPatronDetail','start','query','string','date-time',TRUE),
+ ('collectionsGetPatronDetail','end','query','string','date-time',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items,validate) VALUES ('collectionsGetPatronDetail','array','object',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsPutPatronInCollections',
+ '/patron/:usrid/collections/:shortname',
+ 'post',
+ 'Put patron into collections.',
+ 'open-ils.collections',
+ 'open-ils.collections.put_into_collections',
+ 'eg_auth_token param.usrid param.shortname param.fee param.note'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsPutPatronInCollections','usrid','path','integer',NULL,TRUE),
+ ('collectionsPutPatronInCollections','shortname','path','string',NULL,TRUE),
+ ('collectionsPutPatronInCollections','fee','query','string','money',FALSE),
+ ('collectionsPutPatronInCollections','note','query','string',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsRemovePatronFromCollections',
+ '/patron/:usrid/collections/:shortname',
+ 'delete',
+ 'Remove patron from collections.',
+ 'open-ils.collections',
+ 'open-ils.collections.remove_from_collections',
+ 'eg_auth_token param.usrid param.shortname'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('collectionsRemovePatronFromCollections','usrid','path','integer',TRUE),
+ ('collectionsRemovePatronFromCollections','shortname','path','string',TRUE)
+ON CONFLICT DO NOTHING;
+
+------- courses
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('activeCourses', '/courses', 'get', 'Retrieve all courses used for course material reservation', 'OpenILS::OpenAPI::Controller::course', 'get_active_courses', 'eg_auth_token every_param.org')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('activeCourses','org','query','integer') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('activeCourses','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('activeRoles', '/courses/public_role_users', 'get', 'Retrieve all public roles used for courses', 'OpenILS::OpenAPI::Controller::course', 'get_all_course_public_roles', 'eg_auth_token every_param.org')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('activeRoles','org','query','integer') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('activeRoles','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveCourse', '/course/:id', 'get', 'Retrieve one detailed course', 'OpenILS::OpenAPI::Controller::course', 'get_course_detail', 'eg_auth_token param.id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveCourse','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveCourse','acmc') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveCourseMaterials', '/course/:id/materials', 'get', 'Retrieve detailed materials for one course', 'OpenILS::OpenAPI::Controller::course', 'get_course_materials', 'param.id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveCourseMaterials','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveCourseMaterials','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveCourseUsers', '/course/:id/public_role_users', 'get', 'Retrieve detailed user list for one course', 'open-ils.courses', 'open-ils.courses.course_users.retrieve', 'param.id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveCourseUsers','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveCourseUsers','array','object') ON CONFLICT DO NOTHING;
+
+--------- put likely stock endpoints into sets --------
+INSERT INTO openapi.endpoint_set_endpoint_map (endpoint, endpoint_set)
+ SELECT e.operation_id, s.name FROM openapi.endpoint e JOIN openapi.endpoint_set s ON (e.path LIKE '/'||RTRIM(s.name,'s')||'%')
+ON CONFLICT DO NOTHING;
+
+-- Global Fieldmapper property-filtering org and user setting types
+INSERT INTO config.settings_group (name,label) VALUES ('openapi','OpenAPI data access control');
+
+INSERT INTO config.org_unit_setting_type (name,label,grp) VALUES ('REST.api.blacklist_properties','Globally filtered Fieldmapper properties','openapi');
+INSERT INTO config.org_unit_setting_type (name,label,grp) VALUES ('REST.api.whitelist_properties','Globally whitelisted Fieldmapper properties','openapi');
+UPDATE config.org_unit_setting_type
+ SET update_perm = (SELECT id FROM permission.perm_list WHERE code = 'ADMIN_OPENAPI' LIMIT 1)
+ WHERE name IN ('REST.api.blacklist_properties','REST.api.whitelist_properties');
+
+INSERT INTO config.usr_setting_type (name,label,grp) VALUES ('REST.api.whitelist_properties','Globally whitelisted Fieldmapper properties','openapi');
+INSERT INTO config.usr_setting_type (name,label,grp) VALUES ('REST.api.blacklist_properties','Globally filtered Fieldmapper properties','openapi');
+
diff --git a/Open-ILS/src/sql/Pg/999.functions.global.sql b/Open-ILS/src/sql/Pg/999.functions.global.sql
index 8a64bfc30e..4109fbe7b7 100644
--- a/Open-ILS/src/sql/Pg/999.functions.global.sql
+++ b/Open-ILS/src/sql/Pg/999.functions.global.sql
@@ -14,6 +14,58 @@
*
*/
+CREATE OR REPLACE FUNCTION actor.verify_passwd(pw_usr integer, pw_type text, test_passwd text) RETURNS boolean AS $f$
+DECLARE
+ pw_salt TEXT;
+ api_enabled BOOL;
+BEGIN
+ /* Returns TRUE if the password provided matches the in-db password.
+ * If the password type is salted, we compare the output of CRYPT().
+ * NOTE: test_passwd is MD5(salt || MD5(password)) for legacy
+ * 'main' passwords.
+ *
+ * Password type 'api' requires that the user be enabled as an
+ * integrator in the openapi.integrator table.
+ */
+
+ IF pw_type = 'api' THEN
+ SELECT enabled INTO api_enabled
+ FROM openapi.integrator
+ WHERE id = pw_usr;
+
+ IF NOT FOUND OR api_enabled IS FALSE THEN
+ -- API integrator account not registered
+ RETURN FALSE;
+ END IF;
+ END IF;
+
+ SELECT INTO pw_salt salt FROM actor.passwd
+ WHERE usr = pw_usr AND passwd_type = pw_type;
+
+ IF NOT FOUND THEN
+ -- no such password
+ RETURN FALSE;
+ END IF;
+
+ IF pw_salt IS NULL THEN
+ -- Password is unsalted, compare the un-CRYPT'ed values.
+ RETURN EXISTS (
+ SELECT TRUE FROM actor.passwd WHERE
+ usr = pw_usr AND
+ passwd_type = pw_type AND
+ passwd = test_passwd
+ );
+ END IF;
+
+ RETURN EXISTS (
+ SELECT TRUE FROM actor.passwd WHERE
+ usr = pw_usr AND
+ passwd_type = pw_type AND
+ passwd = CRYPT(test_passwd, pw_salt)
+ );
+END;
+$f$ STRICT LANGUAGE plpgsql;
+
CREATE OR REPLACE FUNCTION actor.usr_merge_rows( table_name TEXT, col_name TEXT, src_usr INT, dest_usr INT ) RETURNS VOID AS $$
DECLARE
sel TEXT;
diff --git a/Open-ILS/src/sql/Pg/sql_file_manifest b/Open-ILS/src/sql/Pg/sql_file_manifest
index 1f6d6c3be5..f94ceb458e 100644
--- a/Open-ILS/src/sql/Pg/sql_file_manifest
+++ b/Open-ILS/src/sql/Pg/sql_file_manifest
@@ -50,6 +50,7 @@ FTS_CONFIG_FILE
500.view.cross-schema.sql
600.schema.oai.sql
+610.schema.openapi.sql
800.fkeys.sql
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openapi.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openapi.sql
new file mode 100755
index 0000000000..07afff5149
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openapi.sql
@@ -0,0 +1,1470 @@
+BEGIN;
+
+-- Necessary pre-seed data
+
+
+INSERT INTO actor.passwd_type (code, name, login, crypt_algo, iter_count)
+ VALUES ('api', 'OpenAPI Integration Password', TRUE, 'bf', 10)
+ON CONFLICT DO NOTHING;
+
+-- Move top-level perms "down" ...
+INSERT INTO permission.grp_perm_map (grp,perm,depth,grantable)
+ SELECT DISTINCT g.id, p.perm, p.depth, p.grantable
+ FROM permission.grp_perm_map p,
+ permission.grp_tree g
+ WHERE g.parent = 1 AND p.grp = 1;
+
+-- ... then remove the User version ...
+DELETE FROM permission.grp_perm_map WHERE grp = 1;
+
+-- ... and add a new branch to the group tree for API perms
+INSERT INTO permission.grp_tree (name, parent, description, application_perm)
+ VALUES ('API Integrator', 1, 'API Integration Accounts', 'group_application.api_integrator');
+
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Patron API', id, 'Patron API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Org Unit API', id, 'Org Unit API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Bib Record API', id, 'Bib Record API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Item Record API', id, 'Item Record API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Holds API', id, 'Holds API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Debt Collection API', id, 'Debt Collection API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+INSERT INTO permission.grp_tree (name, parent, description, application_perm) SELECT 'Course Reserves API', id, 'Course Reserves API', 'group_application.api_integrator' FROM permission.grp_tree WHERE name = 'API Integrator';
+
+INSERT INTO permission.perm_list (code) VALUES
+ ('group_application.api_integrator'),
+ ('API_LOGIN'),
+ ('REST.api'),
+ ('REST.api.patrons'),
+ ('REST.api.orgs'),
+ ('REST.api.bibs'),
+ ('REST.api.items'),
+ ('REST.api.holds'),
+ ('REST.api.collections'),
+ ('REST.api.courses')
+ --- ... etc
+ON CONFLICT DO NOTHING;
+
+INSERT INTO permission.grp_perm_map (grp,perm,depth)
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='API Integrator' AND p.code IN ('API_LOGIN','REST.api')
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Patron API' AND p.code = 'REST.api.patrons'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Org Unit API' AND p.code = 'REST.api.orgs'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Bib Record API' AND p.code = 'REST.api.bibs'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Item Record API' AND p.code = 'REST.api.items'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Holds API' AND p.code = 'REST.api.holds'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Debt Collection API' AND p.code = 'REST.api.collections'
+ UNION
+ SELECT g.id, p.id, 0 FROM permission.grp_tree g, permission.perm_list p WHERE g.name='Course Reserves API' AND p.code = 'REST.api.courses'
+ON CONFLICT DO NOTHING;
+
+DROP SCHEMA IF EXISTS openapi CASCADE;
+CREATE SCHEMA IF NOT EXISTS openapi;
+
+CREATE TABLE IF NOT EXISTS openapi.integrator (
+ id INT PRIMARY KEY REFERENCES actor.usr (id),
+ enabled BOOL NOT NULL DEFAULT TRUE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.json_schema_datatype (
+ name TEXT PRIMARY KEY,
+ label TEXT NOT NULL UNIQUE,
+ description TEXT
+);
+INSERT INTO openapi.json_schema_datatype (name,label) VALUES
+ ('boolean','Boolean'),
+ ('string','String'),
+ ('integer','Integer'),
+ ('number','Number'),
+ ('array','Array'),
+ ('object','Object')
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.json_schema_format (
+ name TEXT PRIMARY KEY,
+ label TEXT NOT NULL UNIQUE,
+ description TEXT
+);
+INSERT INTO openapi.json_schema_format (name,label) VALUES
+ ('date-time','Timestamp'),
+ ('date','Date'),
+ ('time','Time'),
+ ('interval','Interval'),
+ ('email','Email Address'),
+ ('uri','URI'),
+ ('identifier','Opaque Identifier'),
+ ('money','Money'),
+ ('float','Floating Point Number'),
+ ('int64','Large Integer'),
+ ('password','Password')
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.rate_limit_definition (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL, -- i18n
+ limit_interval INTERVAL NOT NULL,
+ limit_count INT NOT NULL
+);
+SELECT SETVAL('openapi.rate_limit_definition_id_seq'::TEXT, 100);
+INSERT INTO openapi.rate_limit_definition (id, name, limit_interval, limit_count) VALUES
+ (1, 'Once per second', '1 second', 1),
+ (2, 'Ten per minute', '1 minute', 10),
+ (3, 'One hunderd per hour', '1 hour', 100),
+ (4, 'One thousand per hour', '1 hour', 1000),
+ (5, 'One thousand per 24 hour period', '24 hours', 1000),
+ (6, 'Ten thousand per 24 hour period', '24 hours', 10000),
+ (7, 'Unlimited', '1 second', 1000000),
+ (8, 'One hundred per second', '1 second', 100)
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint (
+ operation_id TEXT PRIMARY KEY,
+ path TEXT NOT NULL,
+ http_method TEXT NOT NULL CHECK (http_method IN ('get','put','post','delete','patch')),
+ security TEXT NOT NULL DEFAULT 'bearerAuth' CHECK (security IN ('bearerAuth','basicAuth','cookieAuth','paramAuth')),
+ summary TEXT NOT NULL,
+ method_source TEXT NOT NULL, -- perl module or opensrf application, tested by regex and assumes opensrf app name contains a "."
+ method_name TEXT NOT NULL,
+ method_params TEXT, -- eg, 'eg_auth_token hold' or 'eg_auth_token eg_user_id circ req.json'
+ active BOOL NOT NULL DEFAULT TRUE,
+ rate_limit INT REFERENCES openapi.rate_limit_definition (id),
+ CONSTRAINT path_and_method_once UNIQUE (path, http_method)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_param (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ name TEXT NOT NULL CHECK (name ~ '^\w+$'),
+ required BOOL NOT NULL DEFAULT FALSE,
+ in_part TEXT NOT NULL DEFAULT 'query' CHECK (in_part IN ('path','query','header','cookie')),
+ fm_type TEXT,
+ schema_type TEXT REFERENCES openapi.json_schema_datatype (name),
+ schema_format TEXT REFERENCES openapi.json_schema_format (name),
+ array_items TEXT REFERENCES openapi.json_schema_datatype (name),
+ default_value TEXT,
+ CONSTRAINT endpoint_and_name_once UNIQUE (endpoint, name),
+ CONSTRAINT format_requires_type CHECK (schema_format IS NULL OR schema_type IS NOT NULL),
+ CONSTRAINT array_items_requires_array_type CHECK (array_items IS NULL OR schema_type = 'array')
+);
+CREATE TABLE IF NOT EXISTS openapi.endpoint_response (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ validate BOOL NOT NULL DEFAULT TRUE,
+ status INT NOT NULL DEFAULT 200,
+ content_type TEXT NOT NULL DEFAULT 'application/json',
+ description TEXT NOT NULL DEFAULT 'Success',
+ fm_type TEXT,
+ schema_type TEXT REFERENCES openapi.json_schema_datatype (name),
+ schema_format TEXT REFERENCES openapi.json_schema_format (name),
+ array_items TEXT REFERENCES openapi.json_schema_datatype (name),
+ CONSTRAINT endpoint_status_content_type_once UNIQUE (endpoint, status, content_type)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.perm_set (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL
+); -- push sequence value
+SELECT SETVAL('openapi.perm_set_id_seq'::TEXT, 1001);
+INSERT INTO openapi.perm_set (id, name) VALUES
+ (1,'Self - API only'),
+ (2,'Patrons - API only'),
+ (3,'Orgs - API only'),
+ (4,'Bibs - API only'),
+ (5,'Items - API only'),
+ (6,'Holds - API only'),
+ (7,'Collections - API only'),
+ (8,'Courses - API only'),
+
+ (101,'Self - standard permissions'),
+ (102,'Patrons - standard permissions'),
+ (103,'Orgs - standard permissions'),
+ (104,'Bibs - standard permissions'),
+ (105,'Items - standard permissions'),
+ (106,'Holds - standard permissions'),
+ (107,'Collections - standard permissions'),
+ (108,'Courses - standard permissions')
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.perm_set_perm_map (
+ id SERIAL PRIMARY KEY,
+ perm_set INT NOT NULL REFERENCES openapi.perm_set (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm INT NOT NULL REFERENCES permission.perm_list (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+INSERT INTO openapi.perm_set_perm_map (perm_set, perm)
+ SELECT 1, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api')
+ UNION
+ SELECT 2, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.patrons')
+ UNION
+ SELECT 3, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.orgs')
+ UNION
+ SELECT 4, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.bibs')
+ UNION
+ SELECT 5, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.items')
+ UNION
+ SELECT 6, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.holds')
+ UNION
+ SELECT 7, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.collections')
+ UNION
+ SELECT 8, id FROM permission.perm_list WHERE code IN ('API_LOGIN','REST.api','REST.api.cources')
+ UNION
+ -- ...
+ SELECT 101, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 102, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN','VIEW_USER')
+ UNION
+ SELECT 103, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 104, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 105, id FROM permission.perm_list WHERE code IN ('OPAC_LOGIN')
+ UNION
+ SELECT 106, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN','VIEW_USER')
+ UNION
+ SELECT 107, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN','VIEW_USER')
+ UNION
+ SELECT 108, id FROM permission.perm_list WHERE code IN ('STAFF_LOGIN')
+
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_perm_set_map (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint(operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm_set INT NOT NULL REFERENCES openapi.perm_set (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set (
+ name TEXT PRIMARY KEY,
+ description TEXT NOT NULL,
+ active BOOL NOT NULL DEFAULT TRUE,
+ rate_limit INT REFERENCES openapi.rate_limit_definition (id)
+);
+INSERT INTO openapi.endpoint_set (name, description) VALUES
+ ('self', 'Methods for retrieving and manipulating your own user account information'),
+ ('orgs', 'Methods for retrieving and manipulating organizational unit information'),
+ ('patrons', 'Methods for retrieving and manipulating patron information'),
+ ('holds', 'Methods for accessing and manipulating hold data'),
+ ('collections', 'Methods for accessing and manipulating patron debt collections data'),
+ ('bibs', 'Methods for accessing and manipulating bibliographic records and related data'),
+ ('items', 'Methods for accessing and manipulating barcoded item records'),
+ ('courses', 'Methods for accessing and manipulating course reserve data')
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_user_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ accessor INT NOT NULL REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_accessor_once UNIQUE (accessor, endpoint)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_user_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ accessor INT NOT NULL REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_set_accessor_once UNIQUE (accessor, endpoint_set)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_ip_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ ip_range INET NOT NULL,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_ip_range_once UNIQUE (ip_range, endpoint)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_ip_rate_limit_map (
+ id SERIAL PRIMARY KEY,
+ ip_range INET NOT NULL,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ rate_limit INT NOT NULL REFERENCES openapi.rate_limit_definition (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_set_ip_range_once UNIQUE (ip_range, endpoint_set)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_endpoint_map (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ CONSTRAINT endpoint_set_endpoint_once UNIQUE (endpoint_set, endpoint)
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_perm_map (
+ id SERIAL PRIMARY KEY,
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm INT NOT NULL REFERENCES permission.perm_list (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_perm_set_map (
+ id SERIAL PRIMARY KEY,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm_set INT NOT NULL REFERENCES openapi.perm_set (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+INSERT INTO openapi.endpoint_set_perm_set_map (endpoint_set, perm_set) VALUES
+ ('self', 1), ('self', 101),
+ ('patrons', 2), ('patrons', 102),
+ ('orgs', 3), ('orgs', 103),
+ ('bibs', 4), ('bibs', 104),
+ ('items', 5), ('items', 105),
+ ('holds', 6), ('holds', 106),
+ ('collections', 7), ('collections', 107),
+ ('courses', 8), ('courses', 108)
+ON CONFLICT DO NOTHING;
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_set_perm_map (
+ id SERIAL PRIMARY KEY,
+ endpoint_set TEXT NOT NULL REFERENCES openapi.endpoint_set (name) ON UPDATE CASCADE ON DELETE CASCADE,
+ perm INT NOT NULL REFERENCES permission.perm_list (id) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS openapi.authen_attempt_log (
+ request_id TEXT PRIMARY KEY,
+ attempt_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ ip_addr INET,
+ cred_user TEXT,
+ token TEXT
+);
+CREATE INDEX authen_cred_user_attempt_time_idx ON openapi.authen_attempt_log (attempt_time, cred_user);
+CREATE INDEX authen_ip_addr_attempt_time_idx ON openapi.authen_attempt_log (attempt_time, ip_addr);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_access_attempt_log (
+ request_id TEXT PRIMARY KEY,
+ attempt_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ endpoint TEXT NOT NULL REFERENCES openapi.endpoint (operation_id) ON UPDATE CASCADE ON DELETE CASCADE,
+ allowed BOOL NOT NULL,
+ ip_addr INET,
+ accessor INT REFERENCES actor.usr (id) ON UPDATE CASCADE ON DELETE CASCADE,
+ token TEXT
+);
+CREATE INDEX access_accessor_attempt_time_idx ON openapi.endpoint_access_attempt_log (accessor, attempt_time);
+
+CREATE TABLE IF NOT EXISTS openapi.endpoint_dispatch_log (
+ request_id TEXT PRIMARY KEY,
+ complete_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ error BOOL NOT NULL
+);
+
+CREATE OR REPLACE FUNCTION actor.verify_passwd(pw_usr integer, pw_type text, test_passwd text) RETURNS boolean AS $f$
+DECLARE
+ pw_salt TEXT;
+ api_enabled BOOL;
+BEGIN
+ /* Returns TRUE if the password provided matches the in-db password.
+ * If the password type is salted, we compare the output of CRYPT().
+ * NOTE: test_passwd is MD5(salt || MD5(password)) for legacy
+ * 'main' passwords.
+ *
+ * Password type 'api' requires that the user be enabled as an
+ * integrator in the openapi.integrator table.
+ */
+
+ IF pw_type = 'api' THEN
+ SELECT enabled INTO api_enabled
+ FROM openapi.integrator
+ WHERE id = pw_usr;
+
+ IF NOT FOUND OR api_enabled IS FALSE THEN
+ -- API integrator account not registered
+ RETURN FALSE;
+ END IF;
+ END IF;
+
+ SELECT INTO pw_salt salt FROM actor.passwd
+ WHERE usr = pw_usr AND passwd_type = pw_type;
+
+ IF NOT FOUND THEN
+ -- no such password
+ RETURN FALSE;
+ END IF;
+
+ IF pw_salt IS NULL THEN
+ -- Password is unsalted, compare the un-CRYPT'ed values.
+ RETURN EXISTS (
+ SELECT TRUE FROM actor.passwd WHERE
+ usr = pw_usr AND
+ passwd_type = pw_type AND
+ passwd = test_passwd
+ );
+ END IF;
+
+ RETURN EXISTS (
+ SELECT TRUE FROM actor.passwd WHERE
+ usr = pw_usr AND
+ passwd_type = pw_type AND
+ passwd = CRYPT(test_passwd, pw_salt)
+ );
+END;
+$f$ STRICT LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION openapi.find_default_endpoint_rate_limit (target_endpoint TEXT) RETURNS openapi.rate_limit_definition AS $f$
+DECLARE
+ def_rl openapi.rate_limit_definition%ROWTYPE;
+BEGIN
+ -- Default rate limits can be applied at the endpoint or endpoint_set level;
+ -- endpoint overrides endpoint_set, and we choose the most restrictive from
+ -- the set if we have to look there.
+ SELECT d.* INTO def_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint e ON (e.rate_limit = d.id)
+ WHERE e.operation_id = target_endpoint;
+
+ IF NOT FOUND THEN
+ SELECT d.* INTO def_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_set es ON (es.rate_limit = d.id)
+ JOIN openapi.endpoint_set_endpoint_map m ON (es.name = m.endpoint_set AND m.endpoint = target_endpoint)
+ -- This ORDER BY calculates the avg time between requests the user would have to wait to perfectly
+ -- avoid rate limiting. So, a bigger wait means it's more restrictive. We take the most restrictive
+ -- set-applied one.
+ ORDER BY EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+ END IF;
+
+ -- If there's no default for the endpoint or set, we provide 1/sec.
+ IF NOT FOUND THEN
+ def_rl.limit_interval := '1 second'::INTERVAL;
+ def_rl.limit_count := 1;
+ END IF;
+
+ RETURN def_rl;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.find_user_endpoint_rate_limit (target_endpoint TEXT, accessing_usr INT) RETURNS openapi.rate_limit_definition AS $f$
+DECLARE
+ def_u_rl openapi.rate_limit_definition%ROWTYPE;
+BEGIN
+ SELECT d.* INTO def_u_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_user_rate_limit_map e ON (e.rate_limit = d.id)
+ WHERE e.endpoint = target_endpoint
+ AND e.accessor = accessing_usr;
+
+ IF NOT FOUND THEN
+ SELECT d.* INTO def_u_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_set_user_rate_limit_map e ON (e.rate_limit = d.id AND e.accessor = accessing_usr)
+ JOIN openapi.endpoint_set_endpoint_map m ON (e.endpoint_set = m.endpoint_set AND m.endpoint = target_endpoint)
+ ORDER BY EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+ END IF;
+
+ RETURN def_u_rl;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.find_ip_addr_endpoint_rate_limit (target_endpoint TEXT, from_ip_addr INET) RETURNS openapi.rate_limit_definition AS $f$
+DECLARE
+ def_i_rl openapi.rate_limit_definition%ROWTYPE;
+BEGIN
+ SELECT d.* INTO def_i_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_ip_rate_limit_map e ON (e.rate_limit = d.id)
+ WHERE e.endpoint = target_endpoint
+ AND e.ip_range && from_ip_addr
+ -- For IPs, we order first by the size of the ranges that we
+ -- matched (mask length), most specific (smallest block of IPs)
+ -- first, then by the restrictiveness of the limit, more restrictive first.
+ ORDER BY MASKLEN(e.ip_range) DESC, EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+
+ IF NOT FOUND THEN
+ SELECT d.* INTO def_i_rl
+ FROM openapi.rate_limit_definition d
+ JOIN openapi.endpoint_set_ip_rate_limit_map e ON (e.rate_limit = d.id AND ip_range && from_ip_addr)
+ JOIN openapi.endpoint_set_endpoint_map m ON (e.endpoint_set = m.endpoint_set AND m.endpoint = target_endpoint)
+ ORDER BY MASKLEN(e.ip_range) DESC, EXTRACT(EPOCH FROM d.limit_interval) / d.limit_count::NUMERIC DESC
+ LIMIT 1;
+ END IF;
+
+ RETURN def_i_rl;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.check_generic_endpoint_rate_limit (target_endpoint TEXT, accessing_usr INT DEFAULT NULL, from_ip_addr INET DEFAULT NULL) RETURNS INT AS $f$
+DECLARE
+ def_rl openapi.rate_limit_definition%ROWTYPE;
+ def_u_rl openapi.rate_limit_definition%ROWTYPE;
+ def_i_rl openapi.rate_limit_definition%ROWTYPE;
+ u_wait INT;
+ i_wait INT;
+BEGIN
+ def_rl := openapi.find_default_endpoint_rate_limit(target_endpoint);
+
+ IF accessing_usr IS NOT NULL THEN
+ def_u_rl := openapi.find_user_endpoint_rate_limit(target_endpoint, accessing_usr);
+ END IF;
+
+ IF from_ip_addr IS NOT NULL THEN
+ def_i_rl := openapi.find_ip_addr_endpoint_rate_limit(target_endpoint, from_ip_addr);
+ END IF;
+
+ -- Now we test the user-based and IP-based limits in their focused way...
+ IF def_u_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.accessor ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.accessor = accessing_usr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ IF def_i_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ -- If there are no user-specific or IP-based limit
+ -- overrides; check endpoint-wide limits for user,
+ -- then IP, and if we were passed neither, then limit
+ -- endpoint access for all users. Better to lock it
+ -- all down than to set the servers on fire.
+ IF COALESCE(u_wait, i_wait) IS NULL AND COALESCE(def_i_rl.id, def_u_rl.id) IS NULL THEN
+ IF accessing_usr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.accessor ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.accessor = accessing_usr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSIF from_ip_addr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSE -- we have no user and no IP, global per-endpoint rate limit?
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.endpoint ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.endpoint_access_attempt_log l
+ WHERE l.endpoint = target_endpoint
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ END IF;
+ END IF;
+
+ -- Send back the largest required wait time, or NULL for no restriction
+ u_wait := GREATEST(u_wait,i_wait);
+ IF u_wait > 0 THEN
+ RETURN u_wait;
+ END IF;
+
+ RETURN NULL;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+CREATE OR REPLACE FUNCTION openapi.check_auth_endpoint_rate_limit (accessing_usr TEXT DEFAULT NULL, from_ip_addr INET DEFAULT NULL) RETURNS INT AS $f$
+DECLARE
+ def_rl openapi.rate_limit_definition%ROWTYPE;
+ def_u_rl openapi.rate_limit_definition%ROWTYPE;
+ def_i_rl openapi.rate_limit_definition%ROWTYPE;
+ u_wait INT;
+ i_wait INT;
+BEGIN
+ def_rl := openapi.find_default_endpoint_rate_limit('authenticateUser');
+
+ IF accessing_usr IS NOT NULL THEN
+ SELECT (openapi.find_user_endpoint_rate_limit('authenticateUser', u.id)).* INTO def_u_rl
+ FROM actor.usr u
+ WHERE u.usrname = accessing_usr;
+ END IF;
+
+ IF from_ip_addr IS NOT NULL THEN
+ def_i_rl := openapi.find_ip_addr_endpoint_rate_limit('authenticateUser', from_ip_addr);
+ END IF;
+
+ -- Now we test the user-based and IP-based limits in their focused way...
+ IF def_u_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.cred_user ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.cred_user = accessing_usr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ IF def_i_rl.id IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_u_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_u_rl.limit_interval
+ ) x
+ WHERE running_count = def_u_rl.limit_count;
+ END IF;
+
+ -- If there are no user-specific or IP-based limit
+ -- overrides; check endpoint-wide limits for user,
+ -- then IP, and if we were passed neither, then limit
+ -- endpoint access for all users. Better to lock it
+ -- all down than to set the servers on fire.
+ IF COALESCE(u_wait, i_wait) IS NULL AND COALESCE(def_i_rl.id, def_u_rl.id) IS NULL THEN
+ IF accessing_usr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO u_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.cred_user ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.cred_user = accessing_usr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSIF from_ip_addr IS NOT NULL THEN
+ SELECT CEIL(EXTRACT(EPOCH FROM (x.attempt_time + def_rl.limit_interval) - NOW())) INTO i_wait
+ FROM (SELECT l.attempt_time,
+ COUNT(*) OVER (PARTITION BY l.ip_addr ORDER BY l.attempt_time DESC) AS running_count
+ FROM openapi.authen_attempt_log l
+ WHERE l.ip_addr = from_ip_addr
+ AND l.attempt_time > NOW() - def_rl.limit_interval
+ ) x
+ WHERE running_count = def_rl.limit_count;
+ ELSE -- we have no user and no IP, global auth attempt rate limit?
+ SELECT CEIL(EXTRACT(EPOCH FROM (l.attempt_time + def_rl.limit_interval) - NOW())) INTO u_wait
+ FROM openapi.authen_attempt_log l
+ WHERE l.attempt_time > NOW() - def_rl.limit_interval
+ ORDER BY l.attempt_time DESC
+ LIMIT 1 OFFSET def_rl.limit_count;
+ END IF;
+ END IF;
+
+ -- Send back the largest required wait time, or NULL for no restriction
+ u_wait := GREATEST(u_wait,i_wait);
+ IF u_wait > 0 THEN
+ RETURN u_wait;
+ END IF;
+
+ RETURN NULL;
+END;
+$f$ STABLE LANGUAGE PLPGSQL;
+
+--===================================== Seed data ==================================
+
+-- ===== authentication
+INSERT INTO openapi.endpoint (operation_id, path, security, http_method, summary, method_source, method_name, method_params, rate_limit) VALUES
+-- Builtin auth-related methods, give all users and IPs 100/s of each
+ ('authenticateUser', '/self/auth', 'basicAuth', 'get', 'Authenticate API user', 'OpenILS::OpenAPI::Controller', 'authenticateUser', 'param.u param.p param.t', 8),
+ ('logoutUser', '/self/auth', 'bearerAuth', 'delete', 'Logout API user', 'open-ils.auth', 'open-ils.auth.session.delete', 'eg_auth_token', 8)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value) VALUES
+ ('authenticateUser','u','query','string',NULL,NULL),
+ ('authenticateUser','p','query','string','password',NULL),
+ ('authenticateUser','t','query','string',NULL,'api')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('authenticateUser','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('authenticateUser','text/plain') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('logoutUser','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('logoutUser','text/plain') ON CONFLICT DO NOTHING;
+
+-- ===== self-service
+-- get/update me
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfProfile', '/self/me', 'get', 'Return patron/user record for logged in user', 'OpenILS::OpenAPI::Controller::patron', 'deliver_user', 'eg_auth_token eg_user_id'),
+ ('selfUpdateParts', '/self/me', 'patch', 'Update portions of the logged in user''s record', 'OpenILS::OpenAPI::Controller::patron', 'update_user_parts', 'eg_auth_token req.json')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveSelfProfile','au') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('selfUpdateParts','object') ON CONFLICT DO NOTHING;
+
+-- get my standing penalties
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'selfActivePenalties',
+ '/self/standing_penalties',
+ 'get',
+ 'Produces patron-visible penalty details for the authorized account',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token eg_user_id "1"'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('selfActivePenalties','array','object') ON CONFLICT DO NOTHING; -- array of fleshed ausp
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'selfPenalty',
+ '/self/standing_penalty/:penaltyid',
+ 'get',
+ 'Retrieve one penalty for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token eg_user_id "1" param.penaltyid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('selfPenalty','penaltyid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+
+
+-- manage my holds
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfHolds', '/self/holds', 'get', 'Return unfilled holds for the authorized account', 'OpenILS::OpenAPI::Controller::hold', 'open_holds', 'eg_auth_token eg_user_id'),
+ ('requestSelfHold', '/self/holds', 'post', 'Request a hold for the authorized account', 'OpenILS::OpenAPI::Controller::hold', 'request_hold', 'eg_auth_token eg_user_id req.json'),
+ ('retrieveSelfHold', '/self/hold/:hold', 'get', 'Retrieve one hold for the logged in user', 'OpenILS::OpenAPI::Controller::hold', 'fetch_user_hold', 'eg_auth_token eg_user_id param.hold'),
+ ('updateSelfHold', '/self/hold/:hold', 'patch', 'Update one hold for the logged in user', 'OpenILS::OpenAPI::Controller::hold', 'update_user_hold', 'eg_auth_token eg_user_id param.hold req.json'),
+ ('cancelSelfHold', '/self/hold/:hold', 'delete', 'Cancel one hold for the logged in user', 'OpenILS::OpenAPI::Controller::hold', 'cancel_user_hold', 'eg_auth_token eg_user_id param.hold "6"')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrieveSelfHold','hold','path','integer',TRUE),
+ ('updateSelfHold','hold','path','integer',TRUE),
+ ('cancelSelfHold','hold','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfHolds','array','object') ON CONFLICT DO NOTHING;
+
+
+-- general xact list
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveSelfXacts',
+ '/self/transactions/:state',
+ 'get',
+ 'Produces a list of transactions of the logged in user',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'transactions_by_state',
+ 'eg_auth_token eg_user_id state param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('retrieveSelfXacts','state','path','string',NULL,NULL,TRUE),
+ ('retrieveSelfXacts','limit','query','integer',NULL,NULL,FALSE),
+ ('retrieveSelfXacts','offset','query','integer',NULL,'0',FALSE),
+ ('retrieveSelfXacts','sort','query','string',NULL,'desc',FALSE),
+ ('retrieveSelfXacts','before','query','string','date-time',NULL,FALSE),
+ ('retrieveSelfXacts','after','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfXacts','array','object') ON CONFLICT DO NOTHING;
+
+-- general xact detail
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveSelfXact',
+ '/self/transaction/:id',
+ 'get',
+ 'Details of one transaction for the logged in user',
+ 'open-ils.actor',
+ 'open-ils.actor.user.transaction.fleshed.retrieve',
+ 'eg_auth_token param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveSelfXact','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfCircs', '/self/checkouts', 'get', 'Open Circs for the logged in user', 'open-ils.circ', 'open-ils.circ.actor.user.checked_out.atomic', 'eg_auth_token eg_user_id'),
+ ('requestSelfCirc', '/self/checkouts', 'post', 'Attempt a circulation for the logged in user', 'OpenILS::OpenAPI::Controller::patron', 'checkout_item', 'eg_auth_token eg_user_id req.json')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfCircs','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveSelfCircHistory',
+ '/self/checkouts/history',
+ 'get',
+ 'Historical Circs for logged in user',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'circulation_history',
+ 'eg_auth_token eg_user_id param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value) VALUES
+ ('retrieveSelfCircHistory','limit','query','integer',NULL,NULL),
+ ('retrieveSelfCircHistory','offset','query','integer',NULL,'0'),
+ ('retrieveSelfCircHistory','sort','query','string',NULL,'desc'),
+ ('retrieveSelfCircHistory','before','query','string','date-time',NULL),
+ ('retrieveSelfCircHistory','after','query','string','date-time',NULL)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveSelfCircHistory','array','object') ON CONFLICT DO NOTHING;
+
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveSelfCirc', '/self/checkout/:id', 'get', 'Retrieve one circulation for the logged in user', 'open-ils.actor', 'open-ils.actor.user.transaction.fleshed.retrieve', 'eg_auth_token param.id'),
+ ('renewSelfCirc', '/self/checkout/:id', 'put', 'Renew one circulation for the logged in user', 'OpenILS::OpenAPI::Controller::patron', 'renew_circ', 'eg_auth_token param.id eg_user_id'),
+ ('checkinSelfCirc', '/self/checkout/:id', 'delete', 'Check in one circulation for the logged in user', 'OpenILS::OpenAPI::Controller::patron', 'checkin_circ', 'eg_auth_token param.id eg_user_id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrieveSelfCirc','id','path','integer',TRUE),
+ ('renewSelfCirc','id','path','integer',TRUE),
+ ('checkinSelfCirc','id','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+
+-- bib, item, and org methods
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOrgList',
+ '/org_units',
+ 'get',
+ 'List of org units',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'flat_org_list',
+ 'every_param.field every_param.comparison every_param.value'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES
+ ('retrieveOrgList','field','query','string'),
+ ('retrieveOrgList','comparison','query','string'),
+ ('retrieveOrgList','value','query','string')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveOrgList','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOneOrg',
+ '/org_unit/:id',
+ 'get',
+ 'One org unit',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'one_org',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveOneOrg','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveOneOrg','aou') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name) VALUES (
+ 'retrieveFullOrgTree',
+ '/org_tree',
+ 'get',
+ 'Full hierarchical tree of org units',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'full_tree'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveFullOrgTree','aou') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePartialOrgTree',
+ '/org_tree/:id',
+ 'get',
+ 'Partial hierarchical tree of org units starting from a specific org unit',
+ 'OpenILS::OpenAPI::Controller::org',
+ 'one_tree',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePartialOrgTree','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrievePartialOrgTree','aou') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'createOneBib',
+ '/bibs',
+ 'post',
+ 'Attempts to create a bibliographic record using MARCXML passed as the request content',
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record.xml.create',
+ 'eg_auth_token req.text param.sourcename'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('createOneBib','sourcename','query','string') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('createOneBib','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'updateOneBib',
+ '/bib/:id',
+ 'put',
+ 'Attempts to update a bibliographic record using MARCXML passed as the request content',
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record.marc.replace',
+ 'eg_auth_token param.id req.text param.sourcename'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('updateOneBib','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('updateOneBib','sourcename','query','string') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('updateOneBib','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'deleteOneBib',
+ '/bib/:id',
+ 'delete',
+ 'Attempts to delete a bibliographic record',
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record_entry.delete',
+ 'eg_auth_token param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('deleteOneBib','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,validate) VALUES ('deleteOneBib','integer',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'updateBREParts',
+ '/bib/:id',
+ 'patch',
+ 'Attempts to update the biblio.record_entry metadata surrounding a bib record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'update_bre_parts',
+ 'eg_auth_token param.id req.json'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('updateBREParts','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('updateBREParts','bre') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOneBib',
+ '/bib/:id',
+ 'get',
+ 'Retrieve a bibliographic record, either full biblio::record_entry object, or just the MARCXML',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_one_bib',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveOneBib','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveOneBib','bre') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('retrieveOneBib','application/xml') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,content_type) VALUES ('retrieveOneBib','application/octet-stream') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveOneBibHoldings',
+ '/bib/:id/holdings',
+ 'get',
+ 'Retrieve the holdings data for a bibliographic record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_one_bib_holdings',
+ 'param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,default_value,required) VALUES
+ ('retrieveOneBibHoldings','id','path','integer',NULL,TRUE),
+ ('retrieveOneBibHoldings','limit','query','integer',NULL,FALSE),
+ ('retrieveOneBibHoldings','offset','query','integer','0',FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveOneBibHoldings','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'bibDisplayFields',
+ '/bib/:id/display_fields',
+ 'get',
+ 'Retrieve display-related data for a bibliographic record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_one_bib_display_fields',
+ 'param.id req.text'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('bibDisplayFields','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('bibDisplayFields','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'newItems',
+ '/items/fresh',
+ 'get',
+ 'Retrieve a list of newly added items',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'fetch_new_items',
+ 'param.limit param.offset param.maxage'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,default_value) VALUES ('newItems','limit','query','integer','0') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,default_value) VALUES ('newItems','offset','query','integer','100') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format) VALUES ('newItems','maxage','query','string','interval') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('newItems','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrieveItem',
+ '/item/:barcode',
+ 'get',
+ 'Retrieve one item by its barcode',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'item_by_barcode',
+ 'param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveItem','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status,schema_type) VALUES ('retrieveItem','Item Lookup Failed','404','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveItem','acp') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'createItem',
+ '/items',
+ 'post',
+ 'Create an item record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'create_or_update_one_item',
+ 'eg_auth_token req.json'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status) VALUES ('createItem','Item Creation Failed','400') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('createItem','acp') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'updateItem',
+ '/item/:barcode',
+ 'patch',
+ 'Update a restricted set of item record fields',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'create_or_update_one_item',
+ 'eg_auth_token req.json param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('updateItem','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status) VALUES ('updateItem','Item Update Failed','400') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('updateItem','acp') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'deleteItem',
+ '/item/:barcode',
+ 'delete',
+ 'Delete one item record',
+ 'OpenILS::OpenAPI::Controller::bib',
+ 'delete_one_item',
+ 'eg_auth_token param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('deleteItem','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,description,status,schema_type) VALUES ('deleteItem','Item Deletion Failed','404','object') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('deleteItem','boolean') ON CONFLICT DO NOTHING;
+
+
+-- === patron (non-self) methods
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'searchPatrons',
+ '/patrons',
+ 'get',
+ 'List of patrons matching requested conditions',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'find_users',
+ 'eg_auth_token every_param.field every_param.comparison every_param.value param.limit param.offset'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES
+ ('searchPatrons','field','query','string'),
+ ('searchPatrons','comparison','query','string'),
+ ('searchPatrons','value','query','string')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value) VALUES
+ ('searchPatrons','offset','query','integer',NULL,'0'),
+ ('searchPatrons','limit','query','integer',NULL,'100')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('searchPatrons','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'verifyUserCredentials',
+ '/patrons/verify',
+ 'get',
+ 'Verify the credentials for a user account',
+ 'open-ils.actor',
+ 'open-ils.actor.verify_user_password',
+ 'eg_auth_token param.barcode param.usrname "" param.password'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('verifyUserCredentials','barcode','query','string',NULL,FALSE),
+ ('verifyUserCredentials','usrname','query','string',NULL,FALSE),
+ ('verifyUserCredentials','password','query','string','password',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('verifyUserCredentials','boolean') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronProfile', '/patron/:userid', 'get', 'Return patron/user record for the requested user', 'OpenILS::OpenAPI::Controller::patron', 'deliver_user', 'eg_auth_token param.userid')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronProfile','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrievePatronProfile','au') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronIdByCardBarcode',
+ '/patrons/by_barcode/:barcode/id',
+ 'get',
+ 'Retrieve patron id by barcode',
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+ 'eg_auth_token param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronIdByCardBarcode','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('patronIdByCardBarcode','integer') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronIdByUsername',
+ '/patrons/by_username/:username/id',
+ 'get',
+ 'Retrieve patron id by username',
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+ 'eg_auth_token "" param.username'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronIdByUsername','username','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('patronIdByUsername','integer') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronByCardBarcode',
+ '/patrons/by_barcode/:barcode',
+ 'get',
+ 'Retrieve patron by barcode',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'user_by_identifier_string',
+ 'eg_auth_token param.barcode'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronByCardBarcode','barcode','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronByCardBarcode','au') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronByUsername',
+ '/patrons/by_username/:username',
+ 'get',
+ 'Retrieve patron by username',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'user_by_identifier_string',
+ 'eg_auth_token "" param.username'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronByUsername','username','path','string',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronByUsername','au') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePatronCircHistory',
+ '/patron/:userid/checkouts/history',
+ 'get',
+ 'Historical Circs for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'circulation_history',
+ 'eg_auth_token param.userid param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('retrievePatronCircHistory','userid','path','integer',NULL,NULL,TRUE),
+ ('retrievePatronCircHistory','limit','query','integer',NULL,NULL,FALSE),
+ ('retrievePatronCircHistory','offset','query','integer',NULL,'0',FALSE),
+ ('retrievePatronCircHistory','sort','query','string',NULL,'desc',FALSE),
+ ('retrievePatronCircHistory','before','query','string','date-time',NULL,FALSE),
+ ('retrievePatronCircHistory','after','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrievePatronCircHistory','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronHolds', '/patron/:userid/holds', 'get', 'Retrieve unfilled holds for a patron', 'OpenILS::OpenAPI::Controller::hold', 'open_holds', 'eg_auth_token param.userid'),
+ ('requestPatronHold', '/patron/:userid/holds', 'post', 'Request a hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'request_hold', 'eg_auth_token param.userid req.json'),
+ ('retrievePatronHold','/patron/:userid/hold/:hold', 'get', 'Retrieve one hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'fetch_user_hold', 'eg_auth_token param.userid param.hold'),
+ ('updatePatronHold', '/patron/:userid/hold/:hold', 'patch', 'Update one hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'update_user_hold', 'eg_auth_token param.userid param.hold req.json'),
+ ('cancelPatronHold', '/patron/:userid/hold/:hold', 'delete', 'Cancel one hold for a patron', 'OpenILS::OpenAPI::Controller::hold', 'cancel_user_hold', 'eg_auth_token param.userid param.hold "6"')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrievePatronHolds','userid','path','integer',TRUE),
+ ('retrievePatronHold','userid','path','integer',TRUE),
+ ('retrievePatronHold','hold','path','integer',TRUE),
+ ('requestPatronHold','userid','path','integer',TRUE),
+ ('updatePatronHold','userid','path','integer',TRUE),
+ ('updatePatronHold','hold','path','integer',TRUE),
+ ('cancelPatronHold','userid','path','integer',TRUE),
+ ('cancelPatronHold','hold','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveHoldPickupLocations', '/holds/pickup_locations', 'get', 'Retrieve all valid hold/reserve pickup locations', 'OpenILS::OpenAPI::Controller::hold', 'valid_hold_pickup_locations', '')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveHoldPickupLocations','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveHold', '/hold/:hold', 'get', 'Retrieve one hold object', 'open-ils.circ', 'open-ils.circ.hold.details.retrieve', 'eg_auth_token param.hold')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveHold','hold','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePatronXacts',
+ '/patron/:userid/transactions/:state',
+ 'get',
+ 'Produces a list of transactions of the specified user',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'transactions_by_state',
+ 'eg_auth_token param.userid state param.limit param.offset param.sort param.before param.after'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('retrievePatronXacts','userid','path','integer',NULL,NULL,TRUE),
+ ('retrievePatronXacts','state','path','string',NULL,NULL,TRUE),
+ ('retrievePatronXacts','limit','query','integer',NULL,NULL,FALSE),
+ ('retrievePatronXacts','offset','query','integer',NULL,'0',FALSE),
+ ('retrievePatronXacts','sort','query','string',NULL,'desc',FALSE),
+ ('retrievePatronXacts','before','query','string','date-time',NULL,FALSE),
+ ('retrievePatronXacts','after','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrievePatronXacts','array','object') ON CONFLICT DO NOTHING;
+
+-- general xact detail
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'retrievePatronXact',
+ '/patron/:userid/transaction/:id',
+ 'get',
+ 'Details of one transaction for the specified user',
+ 'open-ils.actor',
+ 'open-ils.actor.user.transaction.fleshed.retrieve',
+ 'eg_auth_token param.id'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronXact','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronXact','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronCircs', '/patron/:userid/checkouts', 'get', 'Open Circs for a patron', 'open-ils.circ', 'open-ils.circ.actor.user.checked_out.atomic', 'eg_auth_token param.userid'),
+ ('requestPatronCirc', '/patron/:userid/checkouts', 'post', 'Attempt a circulation for a patron', 'OpenILS::OpenAPI::Controller::patron', 'checkout_item', 'eg_auth_token param.userid req.json')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrievePatronCircs','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('requestPatronCirc','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrievePatronCircs','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrievePatronCirc', '/patron/:userid/checkout/:id', 'get', 'Retrieve one circulation for the specified user', 'open-ils.actor', 'open-ils.actor.user.transaction.fleshed.retrieve', 'eg_auth_token param.id'),
+ ('renewPatronCirc', '/patron/:userid/checkout/:id', 'put', 'Renew one circulation for the specified user', 'OpenILS::OpenAPI::Controller::patron', 'renew_circ', 'eg_auth_token param.id param.userid'),
+ ('checkinPatronCirc', '/patron/:userid/checkout/:id', 'delete', 'Check in one circulation for the specified user', 'OpenILS::OpenAPI::Controller::patron', 'checkin_circ', 'eg_auth_token param.id param.userid')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('retrievePatronCirc','userid','path','integer',TRUE),
+ ('retrievePatronCirc','id','path','integer',TRUE),
+ ('renewPatronCirc','userid','path','integer',TRUE),
+ ('renewPatronCirc','id','path','integer',TRUE),
+ ('checkinPatronCirc','userid','path','integer',TRUE),
+ ('checkinPatronCirc','id','path','integer',TRUE)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronATEvents',
+ '/patron/:userid/triggered_events',
+ 'get',
+ 'Retrieve a list of A/T events for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_at_events',
+ 'eg_auth_token param.userid param.limit param.offset param.before param.after every_param.hook'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('patronATEvents','userid','path','integer',NULL,NULL,TRUE),
+ ('patronATEvents','limit','query','integer',NULL,'100',FALSE),
+ ('patronATEvents','offset','query','integer',NULL,'0',FALSE),
+ ('patronATEvents','before','query','string','date-time',NULL,FALSE),
+ ('patronATEvents','after','query','string','date-time',NULL,FALSE),
+ ('patronATEvents','hook','query','string',NULL,NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronATEvents','array','integer') ON CONFLICT DO NOTHING; -- array of ausp ids
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronATEvent',
+ '/patron/:userid/triggered_event/:eventid',
+ 'get',
+ 'Retrieve one penalty for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_at_events',
+ 'eg_auth_token param.userid "1" "0" "" "" "" param.eventid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronATEvent','eventid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronATEvent','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronActivePenalties',
+ '/patron/:userid/standing_penalties',
+ 'get',
+ 'Retrieve all penalty details for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token param.userid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronActivePenalties','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronActivePenalties','array','integer') ON CONFLICT DO NOTHING; -- array of ausp ids
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronPenalty',
+ '/patron/:userid/standing_penalty/:penaltyid',
+ 'get',
+ 'Retrieve one penalty for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'standing_penalties',
+ 'eg_auth_token param.userid "0" param.penaltyid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronPenalty','penaltyid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronPenalty','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronActiveMessages',
+ '/patron/:userid/messages',
+ 'get',
+ 'Retrieve all active message ids for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_messages',
+ 'eg_auth_token param.userid "0"'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronActiveMessages','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronActiveMessages','array','integer') ON CONFLICT DO NOTHING; -- array of aum ids
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronMessage',
+ '/patron/:userid/message/:msgid',
+ 'get',
+ 'Retrieve one message for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_messages',
+ 'eg_auth_token param.userid "0" param.msgid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessage','msgid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessage','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronMessage','aum') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronMessageUpdate',
+ '/patron/:userid/message/:msgid',
+ 'patch',
+ 'Update one message for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'update_usr_message',
+ 'eg_auth_token eg_user_id param.userid param.msgid req.json'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageUpdate','msgid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageUpdate','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('patronMessageUpdate','aum') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronMessageArchive',
+ '/patron/:userid/message/:msgid',
+ 'delete',
+ 'Archive one message for a patron',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'archive_usr_message',
+ 'eg_auth_token eg_user_id param.userid param.msgid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageArchive','msgid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('patronMessageArchive','userid','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type) VALUES ('patronMessageArchive','boolean') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'patronActivityLog',
+ '/patron/:userid/activity',
+ 'get',
+ 'Retrieve patron activity (authen, authz, etc)',
+ 'OpenILS::OpenAPI::Controller::patron',
+ 'usr_activity',
+ 'eg_auth_token param.userid param.maxage param.limit param.offset param.sort'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,default_value,required) VALUES
+ ('patronActivityLog','userid','path','integer',NULL,NULL,TRUE),
+ ('patronActivityLog','limit','query','integer',NULL,'100',FALSE),
+ ('patronActivityLog','offset','query','integer',NULL,'0',FALSE),
+ ('patronActivityLog','sort','query','string',NULL,'desc',FALSE),
+ ('patronActivityLog','maxage','query','string','date-time',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('patronActivityLog','array','object') ON CONFLICT DO NOTHING;
+
+------- collections
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsPatronsOfInterest',
+ '/collections/:shortname/users_of_interest',
+ 'get',
+ 'List of patrons to consider for collections based on the search criteria provided.',
+ 'open-ils.collections',
+ 'open-ils.collections.users_of_interest.retrieve',
+ 'eg_auth_token param.fine_age param.fine_amount param.shortname'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsPatronsOfInterest','shortname','path','string',NULL,TRUE),
+ ('collectionsPatronsOfInterest','fine_age','query','integer',NULL,TRUE),
+ ('collectionsPatronsOfInterest','fine_amount','query','string','money',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items,validate) VALUES ('collectionsPatronsOfInterest','array','object',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsPatronsOfInterestWarning',
+ '/collections/:shortname/users_of_interest/warning',
+ 'get',
+ 'List of patrons with the PATRON_EXCEEDS_COLLECTIONS_WARNING penalty to consider for collections based on the search criteria provided.',
+ 'open-ils.collections',
+ 'open-ils.collections.users_of_interest.warning_penalty.retrieve',
+ 'eg_auth_token param.shortname param.min_age param.max_age'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsPatronsOfInterestWarning','shortname','path','string',NULL,TRUE),
+ ('collectionsPatronsOfInterestWarning','min_age','query','string','date-time',FALSE),
+ ('collectionsPatronsOfInterestWarning','max_age','query','string','date-time',FALSE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items,validate) VALUES ('collectionsPatronsOfInterestWarning','array','object',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsGetPatronDetail',
+ '/patron/:usrid/collections/:shortname',
+ 'get',
+ 'Get collections-related transaction details for a patron.',
+ 'open-ils.collections',
+ 'open-ils.collections.user_transaction_details.retrieve',
+ 'eg_auth_token param.start param.end param.shortname every_param.usrid'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsGetPatronDetail','usrid','path','integer',NULL,TRUE),
+ ('collectionsGetPatronDetail','shortname','path','string',NULL,TRUE),
+ ('collectionsGetPatronDetail','start','query','string','date-time',TRUE),
+ ('collectionsGetPatronDetail','end','query','string','date-time',TRUE)
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items,validate) VALUES ('collectionsGetPatronDetail','array','object',FALSE) ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsPutPatronInCollections',
+ '/patron/:usrid/collections/:shortname',
+ 'post',
+ 'Put patron into collections.',
+ 'open-ils.collections',
+ 'open-ils.collections.put_into_collections',
+ 'eg_auth_token param.usrid param.shortname param.fee param.note'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,schema_format,required) VALUES
+ ('collectionsPutPatronInCollections','usrid','path','integer',NULL,TRUE),
+ ('collectionsPutPatronInCollections','shortname','path','string',NULL,TRUE),
+ ('collectionsPutPatronInCollections','fee','query','string','money',FALSE),
+ ('collectionsPutPatronInCollections','note','query','string',NULL,FALSE)
+ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES (
+ 'collectionsRemovePatronFromCollections',
+ '/patron/:usrid/collections/:shortname',
+ 'delete',
+ 'Remove patron from collections.',
+ 'open-ils.collections',
+ 'open-ils.collections.remove_from_collections',
+ 'eg_auth_token param.usrid param.shortname'
+) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES
+ ('collectionsRemovePatronFromCollections','usrid','path','integer',TRUE),
+ ('collectionsRemovePatronFromCollections','shortname','path','string',TRUE)
+ON CONFLICT DO NOTHING;
+
+
+------- courses
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('activeCourses', '/courses', 'get', 'Retrieve all courses used for course material reservation', 'OpenILS::OpenAPI::Controller::course', 'get_active_courses', 'eg_auth_token every_param.org')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('activeCourses','org','query','integer') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('activeCourses','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('activeRoles', '/courses/public_role_users', 'get', 'Retrieve all public roles used for courses', 'OpenILS::OpenAPI::Controller::course', 'get_all_course_public_roles', 'eg_auth_token every_param.org')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type) VALUES ('activeRoles','org','query','integer') ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('activeRoles','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveCourse', '/course/:id', 'get', 'Retrieve one detailed course', 'OpenILS::OpenAPI::Controller::course', 'get_course_detail', 'eg_auth_token param.id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveCourse','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,fm_type) VALUES ('retrieveCourse','acmc') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveCourseMaterials', '/course/:id/materials', 'get', 'Retrieve detailed materials for one course', 'OpenILS::OpenAPI::Controller::course', 'get_course_materials', 'param.id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveCourseMaterials','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveCourseMaterials','array','object') ON CONFLICT DO NOTHING;
+
+INSERT INTO openapi.endpoint (operation_id, path, http_method, summary, method_source, method_name, method_params) VALUES
+ ('retrieveCourseUsers', '/course/:id/public_role_users', 'get', 'Retrieve detailed user list for one course', 'open-ils.courses', 'open-ils.courses.course_users.retrieve', 'param.id')
+ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_param (endpoint,name,in_part,schema_type,required) VALUES ('retrieveCourseUsers','id','path','integer',TRUE) ON CONFLICT DO NOTHING;
+INSERT INTO openapi.endpoint_response (endpoint,schema_type,array_items) VALUES ('retrieveCourseUsers','array','object') ON CONFLICT DO NOTHING;
+
+--------- put likely stock endpoints into sets --------
+
+INSERT INTO openapi.endpoint_set_endpoint_map (endpoint, endpoint_set)
+ SELECT e.operation_id, s.name FROM openapi.endpoint e JOIN openapi.endpoint_set s ON (e.path LIKE '/'||RTRIM(s.name,'s')||'%')
+ON CONFLICT DO NOTHING;
+
+-- Check that all endpoints are in sets -- should return 0 rows
+SELECT * FROM openapi.endpoint e WHERE NOT EXISTS (SELECT 1 FROM openapi.endpoint_set_endpoint_map WHERE endpoint = e.operation_id);
+
+-- Global Fieldmapper property-filtering org and user settings
+INSERT INTO config.settings_group (name,label) VALUES ('openapi','OpenAPI data access control');
+
+INSERT INTO config.org_unit_setting_type (name,label,grp) VALUES ('REST.api.blacklist_properties','Globally filtered Fieldmapper properties','openapi');
+INSERT INTO config.org_unit_setting_type (name,label,grp) VALUES ('REST.api.whitelist_properties','Globally whitelisted Fieldmapper properties','openapi');
+
+UPDATE config.org_unit_setting_type
+ SET update_perm = (SELECT id FROM permission.perm_list WHERE code = 'ADMIN_OPENAPI' LIMIT 1)
+ WHERE name IN ('REST.api.blacklist_properties','REST.api.whitelist_properties');
+
+INSERT INTO config.usr_setting_type (name,label,grp) VALUES ('REST.api.whitelist_properties','Globally whitelisted Fieldmapper properties','openapi');
+INSERT INTO config.usr_setting_type (name,label,grp) VALUES ('REST.api.blacklist_properties','Globally filtered Fieldmapper properties','openapi');
+
+COMMIT;
+
+/* -- Some extra example permission setup, to allow (basically) readonly patron retrieve
+
+INSERT INTO permission.perm_list (code,description) VALUES ('REST.api.patrons.detail.read','Permission meant to facilitate read-only patron-related API access');
+INSERT INTO openapi.perm_set (name) values ('Patron Detail, Readonly');
+INSERT INTO openapi.perm_set_perm_map (perm_set,perm) SELECT s.id, p.id FROM openapi.perm_set s, permission.perm_list p WHERE p.code IN ('REST.api', 'REST.api.patrons.detail.read') AND s.name='Patron RO';
+INSERT INTO openapi.endpoint_perm_set_map (endpoint,perm_set) SELECT 'retrievePatronProfile', s.id FROM openapi.perm_set s WHERE s.name='Patron RO';
+
+*/
diff --git a/Open-ILS/src/support-scripts/api_ctl b/Open-ILS/src/support-scripts/api_ctl
new file mode 100755
index 0000000000..38db01ab14
--- /dev/null
+++ b/Open-ILS/src/support-scripts/api_ctl
@@ -0,0 +1,2043 @@
+#!/usr/bin/perl
+
+# ---------------------------------------------------------------
+# Copyright (C) 2024 Equinox Open Library Initiative, Inc.
+# Mike Rylander <mrylander(a)gmail.com>
+
+# This program 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 2
+# of the License, or (at your option) any later version.
+
+# This program 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.
+# ---------------------------------------------------------------
+
+use strict;
+use warnings;
+
+use DBI;
+use Getopt::Long;
+use Data::Dumper;
+use IO::Prompter;
+use Digest::MD5 qw/md5_hex/;
+
+my $raise_db_error = 0;
+
+# Globals for the command line options:
+my $opt_lockfile = '/tmp/api_ctl-LOCK';
+my $help = 0;
+my $from_command = 0;
+
+# Database connection options with defaults:
+my $db_user = $ENV{PGUSER} || 'evergreen';
+my $db_host = $ENV{PGHOST} || 'localhost';
+my $db_db = $ENV{PGDATABASE} || 'evergreen';
+my $db_pw = $ENV{PGPASSWORD} || 'evergreen';
+my $db_port = $ENV{PGPORT} || 5432;
+
+# State globals
+my %state;
+my @command_stack;
+
+
+GetOptions(
+ 'lock-file=s' => \$opt_lockfile,
+ 'dbuser=s' => \$db_user,
+ 'dbhost=s' => \$db_host,
+ 'dbname=s' => \$db_db,
+ 'dbpw=s' => \$db_pw,
+ 'dbport=i' => \$db_port,
+ 'dbraise-error' => \$raise_db_error,
+ 'command' => sub { $from_command++; die '!FINISH' }, # this is similar to --, to leave the rest of @ARGV alone, but also know that it's meant to be a one-shot
+ 'help' => \$help
+);
+
+help() if ($help);
+
+sub help {
+ print <<HELP;
+
+...man page here...
+
+HELP
+ exit;
+}
+
+my $dsn = "dbi:Pg:dbname=$db_db;host=$db_host;port=$db_port;application_name='api_ctl';sslmode=allow";
+
+my $dbh = DBI->connect(
+ $dsn, $db_user, $db_pw,
+ { AutoCommit => 1,
+ pg_expand_array => 0,
+ pg_bool_tf => 0,
+ RaiseError => $raise_db_error
+ }
+) || die "Could not connect to the database\n";
+
+push(@ARGV, 'quit') if $from_command;
+exit next_cmd('OpenAPI:' => [qw/api integrator control details/]);
+
+#------------------
+
+sub prompt_user_input_return_first {
+ my $next = prompt @_;
+ $next =~ s/^\s+//;
+ $next =~ s/\s+$//;
+ my @parts = split /\s+/, $next;
+ $next = shift @parts;
+ push @ARGV, @parts;
+ return $next
+}
+
+sub list_or_argv {
+ my $prompt = shift;
+ my $options = shift;
+ return shift @ARGV if @ARGV;
+ print "\n* Command path: " . join('/', @command_stack) . "\n" if @command_stack and !$from_command;
+ return prompt_user_input_return_first($prompt => -stdio, -comp => $options, @_);
+}
+
+sub number_menu_or_argv {
+ return shift @ARGV if @ARGV;
+ return menu_or_argv(@_, -number);
+}
+
+sub menu_or_argv {
+ return shift @ARGV if @ARGV;
+ my $prompt = shift;
+ my $options = shift;
+ print "\n* Command path: " . join('/', @command_stack) . "\n" if @command_stack and !$from_command;
+ return prompt_user_input_return_first($prompt => -stdio, -menu => $options, @_);
+}
+
+sub menu_spec_from_table {
+ my $table = shift;
+ my $label = shift;
+ my $value = shift;
+ my $no_value_label = shift;
+ my $no_value_value = shift;
+
+ my $spec = {};
+ $$spec{" $no_value_label"} = $no_value_value if $no_value_label;
+
+ for my $row ($dbh->selectall_array("SELECT $label as \"$label\", $value FROM $table", { Slice => {} })) {
+ $$spec{$$row{$label}} = $$row{$value};
+ }
+
+ return $spec;
+}
+
+sub permission_menu_spec {
+ return { map { $_->{code} . ': ' . $_->{description} => $_->{id} } $dbh->selectall_array('SELECT * FROM permission.perm_list', { Slice => {} }) };
+}
+
+sub depth_menu_spec {
+ return menu_spec_from_table('actor.org_unit_type','name','depth');
+}
+
+sub value_or_argv {
+ my $prompt = shift;
+ return shift @ARGV if @ARGV;
+ return prompt $prompt, -stdio, -verbatim, @_;
+}
+
+sub prompted_value {
+ my $prompt = shift;
+ return prompt -prompt => $prompt, -stdio, -verbatim, @_;
+}
+
+sub pw_value {
+ my $prompt = shift;
+ return prompted_value($prompt, -echo => '*');
+}
+
+sub yn {
+ my $prompt = shift;
+ return prompt $prompt, -stdio, -yes, @_;
+}
+
+sub next_cmd {
+ my $prompt = shift;
+ my $options = shift;
+ push @$options, 'back' if @command_stack;
+ push @$options, 'show', 'quit';
+ my $result = 0;
+ while (my $cmd = list_or_argv($prompt => $options)) {
+ if ($cmd eq 'top') { # special case to go back all the way to the top
+ if (@command_stack) {
+ unshift @ARGV, 'top';
+ last;
+ }
+ next;
+ }
+
+ next if ($cmd eq 'show' and show_state(1)); # special case to show the loaded state
+ last if ($cmd eq 'back'); # special case to go back one command level
+ quit() if ($cmd eq 'quit'); # special case to leave
+
+ unless (grep {$_ eq $cmd} @$options) {
+ warn "Command [$cmd] not known\n" if ($cmd ne '');
+ next;
+ }
+
+ push @command_stack, $cmd;
+ my $subname = join '_', @command_stack;
+ $result = &{\&{$subname}}();
+ pop @command_stack;
+
+ }
+ return $result;
+}
+
+sub quit { exit }
+
+sub show_state {
+ my $immediate_details = shift;
+
+ print '-' x 50 ."\n";
+ my $sep = 0;
+
+ if ($state{control}{perm_set}) {
+ print "\n" if $sep;
+ print " == Permission Set Name: $state{control}{perm_set}{name}\n";
+ if ($immediate_details || $state{details} || $state{control_details}|| $state{control_perm_sets_details}) {
+ print " -- ID: $state{control}{perm_set}{id}\n";
+ print " -- Permissions: ".join(', ', map {$$_{code}} @{$state{control}{perm_set}{perms}})."\n";
+ print " -- Endpoints: ".join(', ', @{$state{control}{perm_set}{endpoints}})."\n";
+ print " -- Sets: ".join(', ', @{$state{control}{perm_set}{sets}})."\n";
+ }
+ $sep++;
+ }
+
+ if ($state{control}{rate_limit}) {
+ print "\n" if $sep;
+ print " == Rate Limit Definition Name: $state{control}{rate_limit}{name}\n";
+ if ($immediate_details || $state{details} || $state{control_details}|| $state{control_rate_limit_details}) {
+ print " -- ID: $state{control}{rate_limit}{id}\n";
+ print " -- Count: $state{control}{rate_limit}{limit_count}\n";
+ print " -- Interval: $state{control}{rate_limit}{limit_interval}\n";
+ print " -- Endpoints: ".join(', ', @{$state{control}{rate_limit}{endpoints}})."\n";
+ print " -- Sets: ".join(', ', @{$state{control}{rate_limit}{sets}})."\n";
+ }
+ $sep++;
+ }
+
+ if ($state{integrator}) {
+ print "\n" if $sep;
+ print " == Integrator username: $state{integrator}{usrname}\n";
+ if ($immediate_details || $state{details} || $state{integrator_details}) {
+ print " -- Lead Account: ".($state{integrator}{master_account} ? 'Yes' : 'No')."\n";
+ print " -- Account Group ID: $state{integrator}{usrgroup}\n";
+ print " -- Main Permission Group: $state{integrator}{profile}{name}\n";
+ print " -- Secondary Perm Groups: ".join(', ', map {$$_{name}} @{$state{integrator}{secondary_groups}})."\n";
+ print " -- Permissions: ".join(', ', map {$$_{code}} @{$state{integrator}{user_perms}})."\n";
+ print " -- Rate Limited Endpoints: ".join(', ', map {"$$_{endpoint} => $$_{name}"} @{$state{integrator}{endpoint_rate_limits}})."\n";
+ print " -- Rate Limited Sets: ".join(', ', map {"$$_{endpoint_set} => $$_{name}"} @{$state{integrator}{endpoint_set_rate_limits}})."\n";
+ print " -- Property Filters: ".join(', ', map {"$$_{name} => $$_{value}"} @{$state{integrator}{filter_settings}})."\n";
+ print " -- Name: $state{integrator}{first_given_name} $state{integrator}{family_name}\n";
+ print " -- Barcode: $state{integrator}{card}{barcode}\n" if $state{integrator}{card};
+ print " -- Email: $state{integrator}{email}\n" if $state{integrator}{email};
+ print " -- Day Phone: $state{integrator}{day_phone}\n" if $state{integrator}{day_phone};
+ if ( my $s = $state{integrator}{login_attempt_summary}) {
+ print " :: Login Attempt Summary\n";
+ print " . First Attempt: $$s{first_attempt}\n";
+ print " . Last Attempt: $$s{last_attempt}\n";
+ print " . Success Count: $$s{success}\n";
+ print " . Fail Count: $$s{failure}\n";
+ }
+ if (@{$state{integrator}{endpoint_access_summary}}) {
+ print " :: Endpoint Access Summary\n";
+ print " . $$_{endpoint}: First: $$_{first_attempt}, Last: $$_{last_attempt}, Success: $$_{success}, Failure: $$_{failure}\n"
+ for (@{$state{integrator}{endpoint_access_summary}});
+ }
+ }
+ $sep++;
+ }
+
+ if ($state{api}{set}) {
+ print "\n" if $sep;
+ print " == API Endpoint Set: $state{api}{set}{name}\n";
+ if ($immediate_details || $state{details} || $state{api_details} || $state{api_sets_details}) {
+ print " -- Description: $state{api}{set}{description}\n";
+ print " -- Active: ".($state{api}{set}{active} ? 'Yes' : 'No')."\n";
+ print " -- Rate Limit: $state{api}{set}{rate_limit}{name}\n" if $state{api}{set}{rate_limit};
+ print " -- IP Ranges: ".join(', ', map {$$_{ip_range}} @{$state{api}{set}{ip_ranges}})."\n";
+ print " -- Endpoints: ".join(', ', @{$state{api}{set}{endpoints}})."\n";
+ print " -- Permissions: ".join(', ', map {$$_{code}} @{$state{api}{set}{perms}})."\n";
+ print " -- Perm Sets: ".join(', ', map {$$_{name}.' ('.join(' ', map{$$_{code}} @{$$_{perms}}).')'} @{$state{api}{set}{perm_sets}})."\n";
+ }
+ $sep++;
+ }
+
+ if ($state{api}{endpoint}) {
+ print "\n" if $sep;
+ print " == API Endpoint Operation ID: $state{api}{endpoint}{operation_id}\n";
+ if ($immediate_details || $state{details} || $state{api_details} || $state{api_endpointss_details}) {
+ print " -- Summary: $state{api}{endpoint}{summary}\n";
+ print " -- Active: ".($state{api}{endpoint}{active} ? 'Yes' : 'No')."\n";
+ print " -- Path: $state{api}{endpoint}{path}\n";
+ print " -- HTTP Method: $state{api}{endpoint}{http_method}\n";
+ print " -- Security: $state{api}{endpoint}{security}\n";
+ print " -- Source: $state{api}{endpoint}{method_source}\n";
+ print " -- Handler: $state{api}{endpoint}{method_name}\n";
+ print " -- Param Map: $state{api}{endpoint}{method_params}\n";
+ print " -- Rate Limit: $state{api}{endpoint}{rate_limit}{name}\n" if $state{api}{endpoint}{rate_limit};
+ print " -- IP Ranges: ".join(', ', map {$$_{ip_range}} @{$state{api}{endpoint}{ip_ranges}})."\n";
+ print " -- Sets: ".join(', ', @{$state{api}{endpoint}{sets}})."\n";
+ print " -- Permissions: ".join(', ', map {$$_{code}} @{$state{api}{endpoint}{perms}})."\n";
+ print " -- Perm Sets: ".join(', ', map {$$_{name}.' ('.join(' ', map{$$_{code}} @{$$_{perms}}).')'} @{$state{api}{endpoint}{perm_sets}})."\n";
+ if (@{$state{api}{endpoint}{params}}) {
+ print " :: Parameters\n";
+ for my $p (@{$state{api}{endpoint}{params}}) {
+ print " >> Name: $$p{name}\n";
+ print " . In: $$p{in_part}\n";
+ print " . Required: ".($$p{required} ? 'Yes' : 'No')."\n";
+ print " . Fieldmapper Type: $$p{fm_type}\n" if $$p{fm_type};
+ print " . JSON Schema Type: $$p{schema_type}\n" if $$p{schema_type};
+ print " . JSON Schema Format: $$p{schema_format}\n" if $$p{schema_format};
+ print " . Array Items Type: $$p{array_items}\n" if $$p{array_items};
+ print " . Default Value: $$p{default_value}\n" if $$p{default_value};
+ }
+ }
+ if (@{$state{api}{endpoint}{responses}}) {
+ print " :: Responses\n";
+ for my $p (@{$state{api}{endpoint}{responses}}) {
+ print " >> HTTP Status Code: $$p{status}\n";
+ print " . Content Type: $$p{content_type}\n";
+ print " . Validate Output: ".($$p{validate} ? 'Yes' : 'No')."\n";
+ print " . Description: $$p{description}\n";
+ print " . Fieldmapper Type: $$p{fm_type}\n" if $$p{fm_type};
+ print " . JSON Schema Type: $$p{schema_type}\n" if $$p{schema_type};
+ print " . JSON Schema Format: $$p{schema_format}\n" if $$p{schema_format};
+ print " . Array Items Type: $$p{array_items}\n" if $$p{array_items};
+ }
+ }
+ }
+ $sep++;
+ }
+
+ print '-' x 50 ."\n";
+ return 1;
+}
+
+sub details {
+ $state{details} = $state{details} ? 0 : 1;
+ print ' ! Global Detail Dispaly: '. ($state{details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub control {
+ next_cmd('Control actions:' => [qw/rate_limit perm_sets details/])
+}
+
+sub control_details {
+ $state{control_details} = $state{control_details} ? 0 : 1;
+ print ' ! Control Detail Dispaly: '. ($state{control_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub control_perm_sets {
+ next_cmd('Control Permission Sets actions:' => [qw/list load unload add remove edit details permissions/])
+}
+
+sub control_perm_sets_permissions {
+ control_perm_sets_load() unless $state{control}{perm_set}{id};
+ next_cmd('Control Permission Sets Permission actions:' => [qw/list add remove/]) if $state{control}{perm_set}{id};
+}
+
+sub control_perm_sets_permissions_list {
+ print "Permission in Set $state{control}{perm_set}{name}\n"
+ . "=================================\n" if !$from_command;
+ print join(
+ "\n",
+ map {
+ "$$_{code}: $$_{description}"
+ } @{$state{control}{perm_set}{perms}}
+ ) . "\n";
+}
+
+sub control_perm_sets_permissions_add {
+ my $new_perm = list_or_argv('New Permission:' => $dbh->selectcol_arrayref('SELECT code FROM permission.perm_list'));
+ if ($new_perm ne '' and yn("Add permission [$new_perm] to Permission Set [$state{control}{perm_set}{name}]?")) {
+ $new_perm = $dbh->selectcol_arrayref('SELECT id FROM permission.perm_list WHERE code = ?', undef, $new_perm)->[0];
+ $dbh->do('INSERT INTO openapi.perm_set_perm_map (perm_set,perm) VALUES (?,?)', undef, $state{control}{perm_set}{id}, $new_perm);
+ reload_control_perm_sets();
+ }
+}
+
+
+sub control_perm_sets_permissions_remove {
+ if (@{$state{control}{perm_set}{perms}}) {
+ my $perm = list_or_argv('Permission:' => [map {$$_{code}} @{$state{control}{perm_set}{perms}}]);
+ if ($perm ne '' and yn("Remove permission [$perm] from Permission Set [$state{control}{perm_set}{name}]?")) {
+ $perm = $dbh->selectcol_arrayref('SELECT id FROM permission.perm_list WHERE code = ?', undef, $perm)->[0];
+ $dbh->do('DELETE FROM openapi.perm_set_perm_map WHERE perm_set = ? AND perm = ?', undef, $state{control}{perm_set}{id}, $perm) if ($perm);
+ reload_control_perm_sets();
+ }
+ }
+}
+
+sub control_perm_sets_details {
+ $state{control_perm_sets_details} = $state{control_perm_sets_details} ? 0 : 1;
+ print ' ! Control Detail Dispaly: '. ($state{control_perm_sets_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub control_perm_sets_list {
+ print "Permission Sets:\n"
+ . "================\n" if !$from_command;
+ print join(
+ "\n",
+ map {
+ "$$_{id}: $$_{name}"
+ } $dbh->selectall_array('SELECT id,name FROM openapi.perm_set ORDER BY name',{Slice => {}})
+ ) . "\n";
+}
+
+sub control_perm_sets_unload {
+ delete $state{control}{perm_set};
+}
+
+sub reload_control_perm_sets {
+ if ($state{control}{perm_set}{id}) {
+ unshift @ARGV, $state{control}{perm_set}{id};
+ control_perm_sets_load();
+ }
+}
+
+sub control_perm_sets_load {
+ my $id = shift || number_menu_or_argv(
+ 'Permission Set:' => menu_spec_from_table('openapi.perm_set', 'name', 'id', '[Cancel]', 0)
+ );
+
+ control_perm_sets_unload();
+ return {} if ($id eq '0');
+
+ $state{control}{perm_set} = $dbh->selectrow_hashref('SELECT * FROM openapi.perm_set WHERE id = ?',undef,$id);
+
+ $state{control}{perm_set}{perms} = $dbh->selectall_arrayref(
+ 'SELECT p.* FROM openapi.perm_set_perm_map m JOIN permission.perm_list p ON (m.perm = p.id) WHERE m.perm_set = ? ORDER BY p.code',
+ {Slice => {}}, $state{control}{perm_set}{id}
+ );
+
+ $state{control}{perm_set}{endpoints} = $dbh->selectcol_arrayref(
+ 'SELECT endpoint FROM openapi.endpoint_perm_set_map WHERE perm_set = ?',
+ undef, $state{control}{perm_set}{id}
+ );
+
+ $state{control}{perm_set}{sets} = $dbh->selectcol_arrayref(
+ 'SELECT endpoint_set FROM openapi.endpoint_set_perm_set_map WHERE perm_set = ?',
+ undef, $state{control}{perm_set}{id}
+ );
+
+
+ return $state{control}{perm_set};
+}
+
+sub control_perm_sets_edit {
+ my $rl = control_perm_sets_load($state{control}{perm_set}{id});
+ if ($rl) {
+ $$rl{name} = prompt "Name [$$rl{name}]:", -def => $$rl{name};
+ $dbh->do("UPDATE openapi.perm_set SET name = ? WHERE id = ?", undef, $$rl{name}, $$rl{id});
+ reload_control_perm_sets();
+ }
+}
+
+sub control_perm_sets_add {
+ control_perm_sets_unload();
+ my $name = prompt "Name:";
+ $dbh->do("INSERT INTO openapi.perm_sets (name) VALUES (?)", undef, $name);
+ control_perm_sets_list();
+}
+
+sub control_perm_sets_remove {
+ if ($state{control}{perm_set}{id} and yn("Continue to remove Permission Set $state{control}{perm_set}{name}?")) {
+ $dbh->do( "DELETE FROM openapi.perm_set WHERE id = ?", undef, $state{control}{perm_set}{id} );
+ control_perm_sets_unload();
+ control_perm_sets_list();
+ }
+}
+
+
+sub control_rate_limit {
+ next_cmd('Control Rate Limit actions:' => [qw/list load unload add remove edit details/])
+}
+
+sub control_rate_limit_details {
+ $state{control_rate_limit_details} = $state{control_rate_limit_details} ? 0 : 1;
+ print ' ! Control Detail Dispaly: '. ($state{control_rate_limit_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub control_rate_limit_list {
+ print "Rate Limit Definitions:\n"
+ . "=======================\n" if !$from_command;
+ print join(
+ "\n",
+ map {
+ "$$_{id}: $$_{name}"
+ } $dbh->selectall_array('SELECT id,name FROM openapi.rate_limit_definition ORDER BY name',{Slice => {}})
+ ) . "\n";
+}
+
+
+sub control_rate_limit_unload {
+ delete $state{control}{rate_limit};
+}
+
+sub reload_control_rate_limit {
+ if ($state{control}{rate_limit}{id}) {
+ unshift @ARGV, $state{control}{rate_limit}{id};
+ control_rate_limit_load();
+ }
+}
+
+sub control_rate_limit_load {
+ my $id = shift || number_menu_or_argv(
+ 'Rate Limit Definition:' => menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[Cancel]', 0)
+ );
+
+ control_rate_limit_unload();
+ return {} if ($id eq '0');
+
+ $state{control}{rate_limit} = $dbh->selectrow_hashref('SELECT * FROM openapi.rate_limit_definition WHERE id = ?',undef,$id);
+
+ $state{control}{rate_limit}{endpoint_ip_ranges} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_ip_rate_limit_map WHERE rate_limit = ?',
+ { Slice => {} }, $state{control}{rate_limit}{id}
+ );
+
+ $state{control}{rate_limit}{endpoint_set_ip_ranges} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_set_ip_rate_limit_map WHERE rate_limit = ?',
+ { Slice => {} }, $state{control}{rate_limit}{id}
+ );
+
+ $state{control}{rate_limit}{endpoint_users} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_user_rate_limit_map WHERE rate_limit = ?',
+ { Slice => {} }, $state{control}{rate_limit}{id}
+ );
+
+ $state{control}{rate_limit}{endpoint_set_users} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_set_user_rate_limit_map WHERE rate_limit = ?',
+ { Slice => {} }, $state{control}{rate_limit}{id}
+ );
+
+ $state{control}{rate_limit}{endpoints} = $dbh->selectcol_arrayref(
+ 'SELECT operation_id FROM openapi.endpoint WHERE rate_limit = ?',
+ undef, $state{control}{rate_limit}{id}
+ );
+
+ $state{control}{rate_limit}{sets} = $dbh->selectcol_arrayref(
+ 'SELECT name FROM openapi.endpoint_set WHERE rate_limit = ?',
+ undef, $state{control}{rate_limit}{id}
+ );
+
+
+ return $state{control}{rate_limit};
+}
+
+sub control_rate_limit_edit {
+ my $rl = control_rate_limit_load($state{control}{rate_limit}{id});
+ if ($rl) {
+ $$rl{name} = prompt "Name [$$rl{name}]:", -def => $$rl{name};
+ $$rl{limit_count} = prompt "Count [$$rl{limit_count}]:", -def => $$rl{limit_count};
+ $$rl{limit_interval} = prompt "Interval [$$rl{limit_interval}]:", -def => $$rl{limit_interval};
+ $dbh->do("UPDATE openapi.rate_limit_definition SET name = ?, limit_count = ?, limit_interval = ? WHERE id = ?", undef, $$rl{name}, $$rl{limit_count}, $$rl{limit_interval}, $$rl{id});
+ reload_control_rate_limit();
+ }
+}
+
+sub control_rate_limit_add {
+ control_rate_limit_unload();
+ my $name = prompt "Name:";
+ my $limit_count = prompt "Count:";
+ my $limit_interval = prompt "Interval:";
+ $dbh->do("INSERT INTO openapi.rate_limit_definition (name, limit_count, limit_interval) VALUES (?,?,?)", undef, $name, $limit_count, $limit_interval);
+ control_rate_limit_list();
+}
+
+sub control_rate_limit_remove {
+ if ($state{control}{rate_limit}{id} and yn("Continue to remove rate limit definition $state{control}{rate_limit}{name}?")) {
+ $dbh->do( "DELETE FROM openapi.rate_limit_definition WHERE id = ?", undef, $state{control}{rate_limit}{id} );
+ control_rate_limit_unload();
+ control_rate_limit_list();
+ }
+}
+
+sub api {
+ next_cmd('API actions:' => [qw/endpoints sets details/])
+}
+
+sub api_details {
+ $state{api_details} = $state{api_details} ? 0 : 1;
+ print ' ! API Detail Dispaly: '. ($state{api_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub api_endpoints {
+ next_cmd('API Endpoint actions:' => [qw/list load unload add remove edit activate deactivate parameters responses sets rate_limits perm_sets/])
+}
+
+sub _collect_endpoint_data {
+ my $ep = shift || {
+ operation_id => '',
+ summary => '',
+ path => '',
+ http_method => 'get',
+ security => 'bearerAuth',
+ method_source=> '',
+ method_name => '',
+ method_params=> 'eg_auth_token',
+ active => 0,
+ rate_limit => { name => 'None', id => 0 }
+ };
+
+
+
+ $$ep{operation_id} = prompt "Operation ID".($$ep{operation_id} ? " [$$ep{operation_id}]" :'').":", -def => $$ep{operation_id} || '';
+ $$ep{summary} = prompt "Summary".($$ep{summary} ? " [$$ep{summary}]" :'').":", -def => $$ep{summary} || '';
+ $$ep{path} = prompt "Path".($$ep{path} ? " [$$ep{path}]" :'').":", -def => $$ep{path} || '';
+ $$ep{http_method} = prompt "HTTP Method [$$ep{http_method}]:", -def => $$ep{http_method}, -comp => [qw/get post put delete patch/];
+ $$ep{security} = prompt "Security [$$ep{security}]:", -def => $$ep{security}, -comp => [map {$_.'Auth'} qw/bearer basic cookie param/];
+ $$ep{method_source}= prompt "Method Source".($$ep{method_source} ? " [$$ep{method_source}]" :'').":", -def => $$ep{method_source} || '';
+ $$ep{method_name} = prompt "Method Name".($$ep{method_name} ? " [$$ep{method_name}]" :'').":", -def => $$ep{method_name} || '';
+ $$ep{method_params}= prompt "Method Params".($$ep{method_params} ? " [$$ep{method_params}]" :'').":", -def => $$ep{method_params} || '';
+ $$ep{active} = prompt "Active [".($$ep{active} ? 'Y' : 'N')."]:", -yn => -def => ($$ep{active} ? 'y':'n');
+ $$ep{rate_limit} = number_menu_or_argv(
+ "Rate Limit".($$ep{rate_limit}? " [$$ep{rate_limit}{name}]" : '').":",
+ menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[None]', 0),
+ -def => (ref $$ep{rate_limit} ? $$ep{rate_limit}{id} : 0)
+ );
+
+ $$ep{active} = $$ep{active} eq 'y' ? 1 : 0;
+ $$ep{rate_limit} = $$ep{rate_limit} eq '0' ? undef : $$ep{rate_limit};
+
+ return $ep;
+}
+
+sub api_endpoints_edit {
+ my $ep = $state{api}{endpoint} || api_endpoints_load();
+ if ($ep) {
+ my $oldname = $$ep{operation_id};
+ _collect_endpoint_data($ep);
+ $dbh->do(
+ "UPDATE openapi.endpoint".
+ " SET operation_id=?, summary=?, active=?, rate_limit=?, path=?, http_method=?,".
+ " security=?, method_source=?, method_name=?, method_params=?".
+ " WHERE operation_id = ?", undef,
+ $$ep{operation_id}, $$ep{summary}, $$ep{active}, $$ep{rate_limit}, $$ep{path}, $$ep{http_method},
+ $$ep{security}, $$ep{method_source}, $$ep{method_name}, $$ep{method_params}, $oldname
+ );
+ reload_api_endpoints();
+ }
+}
+
+sub api_endpoints_remove {
+ if ($state{api}{endpoint}{operation_id} and yn("Continue to remove endpoint $state{api}{endpoint}{operation_id}?")) {
+ $dbh->do( "DELETE FROM openapi.endpoint WHERE operation_id = ?", undef, $state{api}{endpoint}{operation_id} );
+ api_endpoints_unload();
+ api_endpoints_list();
+ }
+}
+
+sub api_endpoints_add {
+ api_endpoints_unload();
+ my $ep = _collect_endpoint_data();
+ $dbh->do(
+ "INSERT INTO openapi.endpoint (operation_id, summary, path, http_method, security, method_source, method_name, method_params, active, rate_limit) ".
+ " VALUES (?,?,?,?,?,?,?,?,?,?)", undef,
+ $$ep{operation_id}, $$ep{summary}, $$ep{path}, $$ep{http_method}, $$ep{security}, $$ep{method_source}, $$ep{method_name}, $$ep{method_params}, $$ep{active}, $$ep{rate_limit}
+ );
+ unshift @ARGV, $$ep{operation_id};
+ api_endpoints_load();
+}
+
+sub api_endpoints_details {
+ $state{api_endpoints_details} = $state{api_endpoints_details} ? 0 : 1;
+ print ' ! API Endpoint Detail Dispaly: '. ($state{api_endpoints_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub api_endpoints_list {
+ print "API Endpoints:\n"
+ . "==============\n" if !$from_command;
+ print join(
+ "\n",
+ map {
+ "$$_{operation_id}: [$$_{http_method} $$_{path}] $$_{summary} " . ($$_{active} ? '(Active)' : '(Inactive)')
+ } $dbh->selectall_array('SELECT * FROM openapi.endpoint ORDER BY operation_id',{Slice => {}})
+ ) . "\n";
+}
+
+sub api_endpoints_activate {
+ my $enabled = shift || 'TRUE';
+ my $oid = $state{api}{endpoint}{operation_id} || value_or_argv('Endpoint Operation ID:');
+ if ($oid ne '') {
+ $dbh->do("UPDATE openapi.endpoint SET active = $enabled WHERE operation_id = ?", undef, $oid);
+ unshift @ARGV, $oid;
+ api_endpoints_load();
+ }
+}
+sub api_endpoints_deactivate { api_endpoints_activate('FALSE') }
+
+sub api_endpoints_sets {
+ return unless $state{api}{endpoint}{operation_id} || api_endpoints_load();
+ next_cmd('API Endpoint Assigned Sets actions:' => [qw/list add remove/])
+}
+
+sub api_endpoints_sets_list {
+ print "API Sets for Endpoint\n"
+ . "=====================\n" if !$from_command;
+ print join("\n", @{$state{api}{endpoint}{sets}})."\n";
+}
+
+sub api_endpoints_sets_remove {
+ return unless @{$state{api}{endpoint}{sets}};
+ if (my $s = value_or_argv('API Set:', -comp => $state{api}{endpoint}{sets})) {
+ if (yn("Remove endpoint $state{api}{endpoint}{operation_id} from set $s?")) {
+ $dbh->do( "DELETE FROM openapi.endpoint_set_endpoint_map WHERE endpoint = ? AND endpoint_set = ?", undef, $state{api}{endpoint}{operation_id}, $s );
+ reload_api_endpoints();
+ }
+ }
+}
+
+sub api_endpoints_sets_add {
+ my $options = $dbh->selectcol_arrayref(
+ 'SELECT s.name FROM openapi.endpoint_set s WHERE name NOT IN (SELECT endpoint_set FROM openapi.endpoint_set_endpoint_map WHERE endpoint = ?)',
+ undef, $state{api}{endpoint}{operation_id}
+ );
+ return unless @$options;
+ if (my $s = value_or_argv('API Set:', -comp => $options)) {
+ if (yn("Add endpoint $state{api}{endpoint}{operation_id} to set $s?")) {
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_set_endpoint_map (endpoint, endpoint_set) VALUES (?,?)",
+ undef, $state{api}{endpoint}{operation_id}, $s
+ );
+ reload_api_endpoints();
+ }
+ }
+}
+
+sub api_endpoints_perm_sets {
+ return unless $state{api}{endpoint}{operation_id} || api_endpoints_load();
+ next_cmd('API Endpoint Permission Set actions:' => [qw/list add remove/])
+}
+
+sub api_endpoints_perm_sets_list {
+ print "Permission Sets for Endpoint\n"
+ . "============================\n" if !$from_command;
+ print join("\n", map { $$_{name} .': '. join(', ', map {$$_{code}} @{$$_{perms}}) } @{$state{api}{endpoint}{perm_sets}})."\n";
+}
+
+sub api_endpoints_perm_sets_remove {
+ return unless @{$state{api}{endpoint}{perm_sets}};
+ if (my $s = number_menu_or_argv('Permission Set:', { map {$$_{name} => $$_{id}} @{$state{api}{endpoint}{perm_sets}} })) {
+ $dbh->do( "DELETE FROM openapi.endpoint_perm_set_map WHERE endpoint = ? AND perm_set = ?", undef, $state{api}{endpoint}{operation_id}, $s );
+ reload_api_endpoints();
+ }
+}
+
+sub api_endpoints_perm_sets_add {
+ my $options = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.perm_set WHERE id NOT IN (SELECT perm_set FROM openapi.endpoint_perm_set_map WHERE endpoint = ?)',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+ return unless @$options;
+ if (my $s = number_menu_or_argv('Permission Set:', { map {$$_{name} => $$_{id}} @$options })) {
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_perm_set_map (endpoint, perm_set) VALUES (?,?)",
+ undef, $state{api}{endpoint}{operation_id}, $s
+ );
+ reload_api_endpoints();
+ }
+}
+
+
+sub api_endpoints_parameters {
+ return unless $state{api}{endpoint}{operation_id} || api_endpoints_load();
+ next_cmd('API Endpoint Parameter actions:' => [qw/list add edit remove/])
+}
+
+sub api_endpoints_parameters_list {
+ print "Parameters for Endpoint\n"
+ . "=======================\n" if !$from_command;
+ print join("\n",
+ map { "$$_{name}: Type [".
+ ($$_{fm_type}||$$_{schema_type}).
+ "] supplied in $$_{in_part}, ".
+ ($$_{required}?'':'not ').'required'.
+ (defined($$_{default_value}) ? "; Default [$$_{default_value}]" : '.')
+ } @{$state{api}{endpoint}{params}}
+ )."\n";
+}
+
+sub api_endpoints_parameters_remove {
+ return unless @{$state{api}{endpoint}{params}};
+ if (my $p = number_menu_or_argv('Endpoint Parameter', {' Cancel' => 0, map {$$_{name}, $$_{id}} @{$state{api}{endpoint}{params}}})) {
+ if ($p ne '0' and yn("Remove parameter from endpoint $state{api}{endpoint}{operation_id}?")) {
+ $dbh->do( "DELETE FROM openapi.endpoint_param WHERE id = ?", undef, $p );
+ reload_api_endpoints();
+ }
+ }
+}
+
+sub _collect_endpoint_parameter_data {
+ my $param = shift || {
+ name => '',
+ in_part => 'query',
+ required => 0,
+ fm_type => undef,
+ schema_type => 'string',
+ schema_format => undef,
+ array_items => undef,
+ default_value => undef
+ };
+
+ $$param{name} = prompt 'Name'.($$param{name}?" [$$param{name}]":'').':', -def => $$param{name} || '';
+ $$param{in_part} = list_or_argv("In part [$$param{in_part}]:" => [qw/path query header cookie/], -def => $$param{in_part});
+ $$param{required} = prompt "Required [".($$param{required}?'Y':'N')."]:", -def => $$param{requried} ? 'y':'n', -yn;
+ $$param{fm_type} = prompt 'Fieldmapper type (empty for none)'.($$param{fm_type}?" [$$param{fm_type}]":'').':', -def => $$param{fm_type} || '';
+ $$param{schema_type} = ($$param{fm_type} eq '') ?
+ menu_or_argv(
+ "JSON Schema type".($$param{schema_type}?" [$$param{schema_type}]":'').':',
+ menu_spec_from_table('openapi.json_schema_datatype', 'label','name','[None]',''), -def => $$param{schema_type} || ''
+ ) : '';
+ $$param{schema_format} = menu_or_argv(
+ "JSON Schema format".($$param{schema_format}?" [$$param{schema_format}]":'').":",
+ menu_spec_from_table('openapi.json_schema_format', 'label','name','[None]',''), -def => $$param{schema_format} || ''
+ );
+ $$param{array_items} = ($$param{schema_type} eq 'array') ?
+ menu_or_argv(
+ "Array item type".($$param{array_items}?" [$$param{array_items}]":'').":",
+ menu_spec_from_table('openapi.json_schema_datatype', 'label','name','[None]',''), -def => $$param{array_items} || ''
+ ) : '';
+ $$param{default_value} = prompt 'Default value (empty for none)'.($$param{default_value}?" [$$param{default_value}]":'').':', -def => $$param{default_value} || '';
+
+ for my $nullable_part ( qw/fm_type schema_type schema_format array_items default_value/ ) {
+ $$param{$nullable_part} = undef if ($$param{$nullable_part} eq '');
+ }
+
+ return $param;
+}
+
+sub api_endpoints_parameters_edit {
+ return unless @{$state{api}{endpoint}{params}};
+ my $p = number_menu_or_argv('Endpoint Parameter', {' Cancel' => 0, map {$$_{name}, $$_{id}} @{$state{api}{endpoint}{params}}});
+ if ($p ne '0') {
+ ($p) = grep { $$_{id} eq $p } @{$state{api}{endpoint}{params}};
+ $p = _collect_endpoint_parameter_data($p);
+ $dbh->do('UPDATE openapi.endpoint_param'.
+ ' SET name = ?,'.
+ ' in_part = ?,'.
+ ' required = ?,'.
+ ' fm_type = ?,'.
+ ' schema_type = ?,'.
+ ' schema_format = ?,'.
+ ' array_items = ?,'.
+ ' default_value = ?'.
+ ' WHERE id = ?', undef,
+ $$p{name}, $$p{in_part}, $$p{required}, $$p{fm_type}, $$p{schema_type},
+ $$p{schema_format}, $$p{array_items}, $$p{default_value}, $$p{id}
+ );
+ reload_api_endpoints();
+ }
+}
+
+sub api_endpoints_parameters_add {
+ return unless $state{api}{endpoint}{operation_id};
+ my $p = _collect_endpoint_parameter_data();
+ $dbh->do('INSERT INTO openapi.endpoint_param (endpoint,name,in_part,required,fm_type,schema_type,schema_format,array_items,default_value)'.
+ ' VALUES (?,?,?,?,?,?,?,?,?)', undef,
+ $state{api}{endpoint}{operation_id}, $$p{name}, $$p{in_part}, $$p{required},
+ $$p{fm_type}, $$p{schema_type}, $$p{schema_format}, $$p{array_items}, $$p{default_value}
+ );
+ reload_api_endpoints();
+}
+
+sub api_endpoints_responses {
+ return unless $state{api}{endpoint}{operation_id} || api_endpoints_load();
+ next_cmd('API Endpoint Responses actions:' => [qw/list add edit remove/])
+}
+
+sub api_endpoints_responses_list {
+ print "Responses for Endpoint\n"
+ . "=======================\n" if !$from_command;
+ print join("\n",
+ map { "$$_{status}: $$_{description}; HTTP Content Type [$$_{content_type}], ".
+ "Data type [".($$_{fm_type}||$$_{schema_type}). "], ".
+ ($$_{validate}?'':'not ').'validated against OpenAPI spec'
+ } @{$state{api}{endpoint}{responses}}
+ )."\n";
+}
+
+sub api_endpoints_responses_remove {
+ return unless @{$state{api}{endpoint}{responses}};
+ my $p = number_menu_or_argv(
+ 'Endpoint Response',
+ {' Cancel' => 0,
+ map { ("$$_{status}: $$_{description}", $$_{id}) } @{$state{api}{endpoint}{responses}}
+ }
+ );
+
+ if ($p ne '0' and yn("Remove response spec from endpoint $state{api}{endpoint}{operation_id}?")) {
+ $dbh->do( "DELETE FROM openapi.endpoint_response WHERE id = ?", undef, $p );
+ reload_api_endpoints();
+ }
+}
+
+sub _collect_endpoint_response_data {
+ my $param = shift || {
+ status => '200',
+ content_type => 'application/json',
+ description => 'Success',
+ validate => 1,
+ fm_type => undef,
+ schema_type => undef,
+ schema_format => undef,
+ array_items => undef,
+ };
+
+ $$param{status} = prompt 'Status'.($$param{status}?" [$$param{status}]":'').':', -def => $$param{status} || '';
+ $$param{content_type} = prompt
+ "HTTP Content Type [$$param{content_type}]:",
+ -comp => ['application/json', 'application/xml', 'text/plain', 'application/octet-stream'],
+ -def => $$param{content_type};
+ $$param{description} = prompt 'Default value (empty for none)'.($$param{description}?" [$$param{description}]":'').':', -def => $$param{description} || '';
+ $$param{validate} = prompt "Validate against OpenAPI spec [".($$param{validate}?'Y':'N')."]:", -def => $$param{validate} ? 'y':'n', -yn;
+ $$param{fm_type} = prompt 'Fieldmapper type (empty for none)'.($$param{fm_type}?" [$$param{fm_type}]":'').':', -def => $$param{fm_type} || '';
+ $$param{schema_type} = ($$param{fm_type} eq '') ?
+ menu_or_argv(
+ "JSON Schema type".($$param{schema_type}?" [$$param{schema_type}]":'').':',
+ menu_spec_from_table('openapi.json_schema_datatype', 'label','name','[None]',''), -def => $$param{schema_type} || ''
+ ) : '';
+ $$param{schema_format} = menu_or_argv(
+ "JSON Schema format".($$param{schema_format}?" [$$param{schema_format}]":'').":",
+ menu_spec_from_table('openapi.json_schema_format', 'label','name','[None]',''), -def => $$param{schema_format} || ''
+ );
+ $$param{array_items} = ($$param{schema_type} eq 'array') ?
+ menu_or_argv(
+ "Array item type".($$param{array_items}?" [$$param{array_items}]":'').":",
+ menu_spec_from_table('openapi.json_schema_datatype', 'label','name','[None]',''), -def => $$param{array_items} || ''
+ ) : '';
+
+ for my $nullable_part ( qw/fm_type schema_type schema_format array_items/ ) {
+ $$param{$nullable_part} = undef if ($$param{$nullable_part} eq '');
+ }
+
+ return $param;
+}
+
+
+sub api_endpoints_responses_edit {
+ return unless @{$state{api}{endpoint}{responses}};
+ my $p = number_menu_or_argv(
+ 'Endpoint Response',
+ {' Cancel' => 0,
+ map { ("$$_{status}: $$_{description}", $$_{id}) } @{$state{api}{endpoint}{responses}}
+ }
+ );
+ if ($p ne '0') {
+ ($p) = grep { $$_{id} eq $p } @{$state{api}{endpoint}{responses}};
+ $p = _collect_endpoint_response_data($p);
+ $dbh->do('UPDATE openapi.endpoint_response'.
+ ' SET status = ?,'.
+ ' content_type = ?,'.
+ ' description = ?,'.
+ ' validate = ?,'.
+ ' fm_type = ?,'.
+ ' schema_type = ?,'.
+ ' schema_format = ?,'.
+ ' array_items = ?'.
+ ' WHERE id = ?', undef,
+ $$p{status}, $$p{content_type}, $$p{description}, $$p{validate},
+ $$p{fm_type}, $$p{schema_type}, $$p{schema_format}, $$p{array_items}, $$p{id}
+ );
+ reload_api_endpoints();
+ }
+}
+
+sub api_endpoints_responses_add {
+ return unless $state{api}{endpoint}{operation_id} || api_endpoints_load();
+ my $p = _collect_endpoint_response_data();
+ $dbh->do('INSERT INTO openapi.endpoint_response (endpoint,status,content_type,description,validate,fm_type,schema_type,schema_format,array_items)'.
+ ' VALUES (?,?,?,?,?,?,?,?,?)', undef,
+ $state{api}{endpoint}{operation_id}, $$p{status}, $$p{content_type}, $$p{description},
+ $$p{validate}, $$p{fm_type}, $$p{schema_type}, $$p{schema_format}, $$p{array_items}
+ );
+ reload_api_endpoints();
+}
+
+sub api_endpoints_rate_limits {
+ return unless $state{api}{endpoint}{operation_id} || api_endpoints_load();
+ next_cmd('API Endpoint rate limit types:' => [qw/ip_address integrator/])
+}
+
+sub api_endpoints_rate_limits_ip_address {
+ next_cmd('API Endpoint IP Address rate limit commands:' => [qw/list add edit remove/])
+}
+
+sub api_endpoints_rate_limits_ip_address_list {
+ return api_endpoints_rate_limits_list('ip_ranges');
+}
+
+sub api_endpoints_rate_limits_integrator {
+ next_cmd('API Endpoint IP Address rate limit commands:' => [qw/list add edit remove/])
+}
+
+sub api_endpoints_rate_limits_integrator_list {
+ return api_endpoints_rate_limits_list('user_rate_limits');
+}
+
+sub get_endpoint_rate_limit_label_list {
+ my $type = shift;
+ my @pile;
+ for my $rl ( @{$state{api}{endpoint}{$type}} ) {
+ my $def = $dbh->selectrow_hashref(
+ 'SELECT * FROM openapi.rate_limit_definition WHERE id = ?',
+ undef, $$rl{rate_limit}
+ );
+
+ my $limited = $type eq 'user_rate_limits' ? $dbh->selectrow_hashref(
+ 'SELECT usrname FROM actor.usr WHERE id = ?',
+ undef, $$rl{accessor}
+ )->{usrname} : $$rl{ip_range};
+
+ push @pile, {"$limited limited to $$def{limit_count} per $$def{limit_interval}" => $$rl{id}};
+ }
+ return @pile;
+}
+
+sub api_endpoints_rate_limits_list {
+ my $type = shift;
+ my $type_label = $type eq 'user_rate_limits' ? 'User-specific' : 'IP Range';
+
+ print "$type_label Endpoint Rate Limits for $state{api}{endpoint}{operation_id}\n"
+ . "===================================================\n" if !$from_command;
+
+ print join("\n", map {
+ my $label = (keys(%$_))[0];
+ $label = "$state{api}{endpoint}{operation_id}: $label" if $from_command;
+ $label;
+ } get_endpoint_rate_limit_label_list($type))."\n";
+}
+
+sub _collect_endpoint_rate_limit_ip_data {
+ my $ep = $state{api}{endpoint} || api_endpoints_load();
+ return unless $ep;
+
+ my $rl = shift || {
+ endpoint => $$ep{operation_id},
+ ip_range => '',
+ rate_limit => undef
+ };
+
+ $$rl{endpoint} = prompt "Endpoint".($$rl{endpoint} ? " [$$rl{endpoint}]" :'').":", -def => $$rl{endpoint} || '';
+ $$rl{ip_range} = prompt "CIDR IP Range".($$rl{ip_range} ? " [$$rl{ip_range}]" :'').":", -def => $$rl{ip_range} || '';
+ $$rl{rate_limit} = number_menu_or_argv(
+ "Rate Limit".($$rl{rate_limit}? " [$$rl{rate_limit}{name}]" : '').":",
+ menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[Cancel]', 0),
+ -def => (ref $$rl{rate_limit} ? $$rl{rate_limit}{id} : 0)
+ );
+
+ $$rl{rate_limit} = $$rl{rate_limit} eq '0' ? undef : $$rl{rate_limit};
+
+ return $rl;
+}
+
+sub api_endpoints_rate_limits_ip_address_add {
+ my $rl = _collect_endpoint_rate_limit_ip_data();
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_ip_rate_limit_map (endpoint, ip_range, rate_limit) VALUES (?,?,?)", undef,
+ $$rl{endpoint}, $$rl{ip_range}, $$rl{rate_limit}
+ );
+ reload_api_endpoints();
+}
+
+sub api_endpoints_rate_limits_ip_address_edit {
+ my @rl_list = get_endpoint_rate_limit_label_list('ip_ranges');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to edit:', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ my ($rl) = grep {$$_{id} == $rl_id} @{$state{api}{endpoints}{ip_ranges}};
+ $rl = _collect_endpoint_rate_limit_ip_data($rl);
+ $dbh->do(
+ "UPDATE openapi.endpoint_ip_rate_limit_map SET endpoint=?, ip_range=?, rate_limit=? WHERE id=?", undef,
+ $$rl{endpoint}, $$rl{ip_range}, $$rl{rate_limit}, $rl_id
+ );
+ reload_api_endpoints();
+}
+
+sub api_endpoints_rate_limits_ip_address_remove {
+ my @rl_list = get_endpoint_rate_limit_label_list('ip_ranges');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to edit:', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ $dbh->do( "DELETE FROM openapi.endpoint_ip_rate_limit_map WHERE id=?", undef, $rl_id);
+ reload_api_endpoints();
+}
+
+sub _collect_endpoint_rate_limit_user_data {
+ my $ep = $state{api}{endpoint} || api_endpoints_load();
+ my $int = $state{integrator}{id} || 0;
+ return unless $ep;
+
+ my $rl = shift || {
+ endpoint => $$ep{operation_id},
+ accessor => $int,
+ rate_limit => 0
+ };
+
+ $$rl{endpoint} = prompt "Endpoint".($$rl{endpoint} ? " [$$rl{endpoint}]" :'').":", -def => $$rl{endpoint} || '';
+ $$rl{accessor} = number_menu_or_argv(
+ "Integrator".($$rl{accessor}? " [ID:$$rl{accessor}]" : '').":",
+ menu_spec_from_table('openapi.integrator natural join actor.usr', 'usrname', 'id', '[Cancel]', 0),
+ -def => $$rl{accessor}
+ );
+ $$rl{rate_limit} = number_menu_or_argv(
+ "Rate Limit".($$rl{rate_limit}? " [ID:$$rl{rate_limit}]" : '').":",
+ menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[Cancel]', 0),
+ -def => (ref $$rl{rate_limit} ? $$rl{rate_limit}{id} : 0)
+ );
+
+ $$rl{accessor} = $$rl{accessor} eq '0' ? undef : $$rl{accessor};
+ $$rl{rate_limit} = $$rl{rate_limit} eq '0' ? undef : $$rl{rate_limit};
+
+ return $rl;
+}
+
+sub api_endpoints_rate_limits_integrator_add {
+ my $rl = _collect_endpoint_rate_limit_user_data();
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_user_rate_limit_map (endpoint, accessor, rate_limit) VALUES (?,?,?)", undef,
+ $$rl{endpoint}, $$rl{accessor}, $$rl{rate_limit}
+ );
+ reload_api_endpoints();
+}
+
+sub api_endpoints_rate_limits_integrator_edit {
+ my @rl_list = get_endpoint_rate_limit_label_list('user_rate_limits');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to edit:', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ my ($rl) = grep {$$_{id} == $rl_id} @{$state{api}{endpoints}{user_rate_limits}};
+ $rl = _collect_endpoint_rate_limit_ip_data($rl);
+ $dbh->do(
+ "UPDATE openapi.endpoint_user_rate_limit_map SET endpoint=?, accessor=?, rate_limit=? WHERE id=?", undef,
+ $$rl{endpoint}, $$rl{accessor}, $$rl{rate_limit}, $rl_id
+ );
+ reload_api_endpoints();
+}
+
+sub api_endpoints_rate_limits_integrator_remove {
+ my @rl_list = get_endpoint_rate_limit_label_list('user_rate_limits');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to remove', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ $dbh->do( "DELETE FROM openapi.endpoint_user_rate_limit_map WHERE id=?", undef, $rl_id);
+ reload_api_endpoints();
+}
+
+
+
+sub api_sets {
+ next_cmd('API Endpoint Set actions:' => [qw/list load unload add remove edit activate deactivate endpoints rate_limits perm_sets/])
+}
+
+sub _collect_set_data {
+ my $set = shift || {
+ name => '',
+ description => '',
+ active => 0,
+ rate_limit => { name => 'None', id => 0 }
+ };
+
+ $$set{name} = prompt "Name [$$set{name}]:", -def => $$set{name};
+ $$set{description} = prompt "Description [$$set{description}]:", -def => $$set{description};
+ $$set{active} = prompt "Active [".($$set{active}?'Y':'N')."]:", -yn => -def => ($$set{active} ? 'y':'n');
+ $$set{rate_limit} = number_menu_or_argv(
+ "Rate Limit".($$set{rate_limit} ? " [$$set{rate_limit}{name}]":'').":",
+ menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[None]', 0),
+ -def => (ref $$set{rate_limit} ? $$set{rate_limit}{id} : 0)
+ );
+
+ $$set{active} = $$set{active} eq 'y' ? 1 : 0;
+ $$set{rate_limit} = $$set{rate_limit} eq '0' ? undef : $$set{rate_limit};
+
+ return $set;
+}
+
+sub api_sets_remove {
+ if ($state{api}{set}{name} and yn("Continue to remove set $state{api}{set}{name}?")) {
+ $dbh->do( "DELETE FROM openapi.endpoint_set WHERE name = ?", undef, $state{api}{set}{name} );
+ api_sets_unload();
+ api_sets_list();
+ }
+}
+
+sub api_sets_add {
+ api_sets_unload();
+ my $set = _collect_set_data();
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_set (name, description, active, rate_limit) VALUES (?,?,?,?)", undef,
+ $$set{name}, $$set{description}, $$set{active}, $$set{rate_limit}
+ );
+ unshift @ARGV, $$set{name};
+ api_sets_load();
+}
+
+sub api_sets_edit {
+ my $set = $state{api}{set} || api_sets_load();
+ if ($set) {
+ my $oldname = $$set{name};
+ _collect_set_data($set);
+ $dbh->do(
+ "UPDATE openapi.endpoint_set SET name = ?, description = ?, active = ?, rate_limit = ? WHERE name = ?", undef,
+ $$set{name}, $$set{description}, $$set{active}, $$set{rate_limit}, $oldname
+ );
+ reload_api_sets();
+ }
+}
+
+sub api_sets_details {
+ $state{api_sets_details} = $state{api_sets_details} ? 0 : 1;
+ print ' ! API Endpoint Set Detail Dispaly: '. ($state{api_sets_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub api_sets_list {
+ print "API Endpoint Sets:\n"
+ . "==============\n" if !$from_command;
+ print join(
+ "\n",
+ map {
+ "$$_{name}: $$_{description} " . ($$_{active} ? '(Active)' : '(Inactive)')
+ } $dbh->selectall_array('SELECT * FROM openapi.endpoint_set ORDER BY name',{Slice => {}})
+ ) . "\n";
+}
+
+sub api_sets_activate {
+ my $enabled = shift || 'TRUE';
+ my $name = $state{api}{set}{name} || value_or_argv('Endpoint Set Name:');
+ if ($name ne '') {
+ $dbh->do("UPDATE openapi.endpoint_set SET active = $enabled WHERE name = ?", undef, $name);
+ unshift @ARGV, $name;
+ api_set_load();
+ }
+}
+sub api_sets_deactivate { api_sets_activate('FALSE') }
+
+sub api_sets_perm_sets {
+ return unless $state{api}{set}{name} || api_sets_load();
+ next_cmd('API Endpoint Set Permission Set actions:' => [qw/list add remove/])
+}
+
+sub api_sets_perm_sets_list {
+ print "Permission Sets for Endpoint Set\n"
+ . "============================\n" if !$from_command;
+ print join("\n", map { $$_{name} .': '. join(', ', map {$$_{code}} @{$$_{perms}}) } @{$state{api}{set}{perm_sets}})."\n";
+}
+
+sub api_sets_perm_sets_remove {
+ return unless @{$state{api}{set}{perm_sets}};
+ if (my $s = number_menu_or_argv('Permission Set:', { map {$$_{name} => $$_{id}} @{$state{api}{set}{perm_sets}} })) {
+ $dbh->do( "DELETE FROM openapi.endpoint_set_perm_set_map WHERE endpoint_set = ? AND perm_set = ?", undef, $state{api}{set}{name}, $s );
+ reload_api_sets();
+ }
+}
+
+sub api_sets_perm_sets_add {
+ my $options = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.perm_set WHERE id NOT IN (SELECT perm_set FROM openapi.endpoint_set_perm_set_map WHERE endpoint_set = ?)',
+ { Slice => {} }, $state{api}{set}{name}
+ );
+ return unless @$options;
+ if (my $s = number_menu_or_argv('API Set:', { map {$$_{name} => $$_{id}} @$options })) {
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_set_perm_set_map (endpoint_set, perm_set) VALUES (?,?)",
+ undef, $state{api}{set}{name}, $s
+ );
+ reload_api_sets();
+ }
+}
+
+
+sub api_sets_endpoints {
+ return unless $state{api}{set}{name} || api_sets_load();
+ next_cmd('API Sets Assigned Endpoint actions:' => [qw/list add remove/])
+}
+
+sub api_sets_endpoints_list {
+ print "API Endpoints for Set\n"
+ . "=====================\n" if !$from_command;
+ print join("\n", @{$state{api}{set}{endpoints}})."\n";
+}
+
+sub api_sets_endpoints_remove {
+ return unless @{$state{api}{set}{endpoints}};
+ if (my $s = value_or_argv('API Endpoint:', -comp => $state{api}{set}{endpoints})) {
+ if (yn("Remove endpoint $s from set $state{api}{set}{name}?")) {
+ $dbh->do( "DELETE FROM openapi.endpoint_set_endpoint_map WHERE endpoint = ? AND endpoint_set = ?", undef, $s, $state{api}{set}{name} );
+ reload_api_sets();
+ }
+ }
+}
+
+sub api_sets_endpoints_add {
+ my $options = $dbh->selectcol_arrayref(
+ 'SELECT s.operation_id FROM openapi.endpoint s WHERE operation_id NOT IN (SELECT endpoint FROM openapi.endpoint_set_endpoint_map WHERE endpoint_set = ?)',
+ undef, $state{api}{set}{name}
+ );
+ return unless @$options;
+ if (my $s = value_or_argv('API Endpoint', -comp => $options)) {
+ if (yn("Add endpoint $s to set $state{api}{set}{name}?")) {
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_set_endpoint_map (endpoint, endpoint_set) VALUES (?,?)",
+ undef, $s, $state{api}{set}{name}
+ );
+ reload_api_sets();
+ }
+ }
+}
+
+sub api_sets_rate_limits {
+ return unless $state{api}{set}{name} || api_sets_load();
+ next_cmd('API Set rate limit types:' => [qw/ip_address integrator/])
+}
+
+sub api_sets_rate_limits_ip_address {
+ next_cmd('API Set IP Address rate limit commands:' => [qw/list add edit remove/])
+}
+
+sub api_sets_rate_limits_ip_address_list {
+ return api_sets_rate_limits_list('ip_ranges');
+}
+
+sub api_sets_rate_limits_integrator {
+ next_cmd('API Set IP Address rate limit commands:' => [qw/list add edit remove/])
+}
+
+sub api_sets_rate_limits_integrator_list {
+ return api_sets_rate_limits_list('user_rate_limits');
+}
+
+sub get_set_rate_limit_label_list {
+ my $type = shift;
+ my @pile;
+ for my $rl ( @{$state{api}{set}{$type}} ) {
+ my $def = $dbh->selectrow_hashref(
+ 'SELECT * FROM openapi.rate_limit_definition WHERE id = ?',
+ undef, $$rl{rate_limit}
+ );
+
+ my $limited = $type eq 'user_rate_limits' ? $dbh->selectrow_hashref(
+ 'SELECT usrname FROM actor.usr WHERE id = ?',
+ undef, $$rl{accessor}
+ )->{usrname} : $$rl{ip_range};
+
+ push @pile, {"$limited limited to $$def{limit_count} per $$def{limit_interval}" => $$rl{id}};
+ }
+ return @pile;
+}
+
+sub api_sets_rate_limits_list {
+ my $type = shift;
+ my $type_label = $type eq 'user_rate_limits' ? 'User-specific' : 'IP Range';
+
+ print "$type_label Endpoint Set Rate Limits for $state{api}{set}{name}\n"
+ . "===================================================\n" if !$from_command;
+
+ print join("\n", map {
+ my $label = (keys(%$_))[0];
+ $label = "$state{api}{set}{name}: $label" if $from_command;
+ $label;
+ } get_set_rate_limit_label_list($type))."\n";
+}
+
+sub _collect_set_rate_limit_ip_data {
+ my $set = $state{api}{set} || api_sets_load();
+ return unless $set;
+
+ my $rl = shift || {
+ endpoint_set => $$set{name},
+ ip_range => '',
+ rate_limit => undef
+ };
+
+ $$rl{endpoint_set} = prompt "Endpoint Set".($$rl{endpoint_set} ? " [$$rl{endpoint_set}]" :'').":", -def => $$rl{endpoint_set} || '';
+ $$rl{ip_range} = prompt "CIDR IP Range".($$rl{ip_range} ? " [$$rl{ip_range}]" :'').":", -def => $$rl{ip_range} || '';
+ $$rl{rate_limit} = number_menu_or_argv(
+ "Rate Limit".($$rl{rate_limit}? " [$$rl{rate_limit}{name}]" : '').":",
+ menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[Cancel]', 0),
+ -def => $$rl{rate_limit} || 0
+ );
+
+ $$rl{rate_limit} = $$rl{rate_limit} eq '0' ? undef : $$rl{rate_limit};
+
+ return $rl;
+}
+
+sub api_sets_rate_limits_ip_address_add {
+ my $rl = _collect_set_rate_limit_ip_data();
+ return unless $$rl{rate_limit};
+
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_set_ip_rate_limit_map (endpoint_set, ip_range, rate_limit) VALUES (?,?,?)", undef,
+ $$rl{endpoint_set}, $$rl{ip_range}, $$rl{rate_limit}
+ );
+ reload_api_sets();
+}
+
+sub api_sets_rate_limits_ip_address_edit {
+ my @rl_list = get_set_rate_limit_label_list('ip_ranges');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to edit:', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ my ($rl) = grep {$$_{id} == $rl_id} @{$state{api}{set}{ip_ranges}};
+ $rl = _collect_set_rate_limit_ip_data($rl);
+ $dbh->do(
+ "UPDATE openapi.endpoint_set_ip_rate_limit_map SET endpoint_set=?, ip_range=?, rate_limit=? WHERE id=?", undef,
+ $$rl{endpoint_set}, $$rl{ip_range}, $$rl{rate_limit}, $rl_id
+ );
+ reload_api_sets();
+}
+
+sub api_sets_rate_limits_ip_address_remove {
+ my @rl_list = get_set_rate_limit_label_list('ip_ranges');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to edit:', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ $dbh->do( "DELETE FROM openapi.endpoint_set_ip_rate_limit_map WHERE id=?", undef, $rl_id);
+ reload_api_sets();
+}
+
+sub _collect_set_rate_limit_user_data {
+ my $set = $state{api}{set} || api_sets_load();
+ my $int = $state{integrator}{id} || 0;
+ return unless $set;
+
+ my $rl = shift || {
+ endpoint_set => $$set{name},
+ accessor => $int,
+ rate_limit => 0
+ };
+
+ $$rl{endpoint_set} = prompt "Endpoint Set".($$rl{endpoint_set} ? " [$$rl{endpoint_set}]" :'').":", -def => $$rl{endpoint_set} || '';
+ $$rl{accessor} = number_menu_or_argv(
+ "Integrator".($$rl{accessor}? " [ID:$$rl{accessor}]" : '').":",
+ menu_spec_from_table('openapi.integrator natural join actor.usr', 'usrname', 'id', '[Cancel]', 0),
+ -def => $$rl{accessor} || 0
+ );
+ $$rl{rate_limit} = number_menu_or_argv(
+ "Rate Limit".($$rl{rate_limit}? " [ID:$$rl{rate_limit}]" : '').":",
+ menu_spec_from_table('openapi.rate_limit_definition', 'name', 'id', '[Cancel]', 0),
+ -def => $$rl{rate_limit} || 0
+ );
+
+ $$rl{accessor} = $$rl{accessor} eq '0' ? undef : $$rl{accessor};
+ $$rl{rate_limit} = $$rl{rate_limit} eq '0' ? undef : $$rl{rate_limit};
+
+ return $rl;
+}
+
+sub api_sets_rate_limits_integrator_add {
+ my $rl = _collect_set_rate_limit_user_data();
+ return unless $$rl{rate_limit} and $$rl{accessor};
+ $dbh->do(
+ "INSERT INTO openapi.endpoint_set_user_rate_limit_map (endpoint_set, accessor, rate_limit) VALUES (?,?,?)", undef,
+ $$rl{endpoint_set}, $$rl{accessor}, $$rl{rate_limit}
+ );
+ reload_api_sets();
+}
+
+sub api_sets_rate_limits_integrator_edit {
+ my @rl_list = get_set_rate_limit_label_list('user_rate_limits');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to edit:', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ my ($rl) = grep {$$_{id} == $rl_id} @{$state{api}{set}{user_rate_limits}};
+ $rl = _collect_set_rate_limit_user_data($rl);
+ $dbh->do(
+ "UPDATE openapi.endpoint_set_user_rate_limit_map SET endpoint_set=?, accessor=?, rate_limit=? WHERE id=?", undef,
+ $$rl{endpoint_set}, $$rl{accessor}, $$rl{rate_limit}, $rl_id
+ );
+ reload_api_sets();
+}
+
+sub api_sets_rate_limits_integrator_remove {
+ my @rl_list = get_set_rate_limit_label_list('user_rate_limits');
+ my $rl_map = { map {%$_} @rl_list };
+ $$rl_map{' [Cancel]'} = 0;
+ my $rl_id = number_menu_or_argv( 'Rate Limit to remove', $rl_map, -def => 0 );
+ return unless $rl_id;
+
+ $dbh->do( "DELETE FROM openapi.endpoint_set_user_rate_limit_map WHERE id=?", undef, $rl_id);
+ reload_api_sets();
+}
+
+
+sub integrator {
+ next_cmd('Integrator actions:' => [qw/list load unload add remove enable disable password
+ details permissions groups global_property_whitelist
+ global_property_blacklist endpoint_property_whitelist
+ endpoint_property_blacklist/]);
+}
+
+sub integrator_global_property_whitelist {
+ my $usrname = $state{integrator}{usrname} || integrator_load()->{usrname};
+ next_cmd('Global Fieldmapper property whitelist actions:' => [qw/list set remove/]);
+}
+
+sub integrator_global_property_blacklist {
+ my $usrname = $state{integrator}{usrname} || integrator_load()->{usrname};
+ next_cmd('Global Fieldmapper property blacklist actions:' => [qw/list set remove/]);
+}
+
+sub integrator_global_property_whitelist_list {
+ return show_global_integrator_property_filter('whitelist');
+}
+
+sub integrator_global_property_blacklist_list {
+ return show_global_integrator_property_filter('blacklist');
+}
+
+sub integrator_endpoint_property_whitelist_list {
+ return show_endpoint_integrator_property_filter('whitelist');
+}
+
+sub integrator_endpoint_property_blacklist_list {
+ return show_endpoint_integrator_property_filter('blacklist');
+}
+
+sub show_global_integrator_property_filter {
+ my $type = shift;
+
+ my $setting_name = "REST.api.${type}_properties";
+
+ return print_filter_list(Global => $type => [ grep { $$_{name} eq $setting_name } @{$state{integrator}{filter_settings}} ]);
+
+}
+
+sub show_endpoint_integrator_property_filter {
+ my $type = shift;
+ my $endpoint = shift || '';
+
+ my $setting_name = "REST.api.${type}_properties.$endpoint";
+
+ return print_filter_list(Endpoint => $type => [ grep { $$_{name} =~ /^$setting_name/ } @{$state{integrator}{filter_settings}} ]);
+}
+
+sub print_filter_list {
+ my $scope = shift;
+ my $type = shift;
+ my $settings = shift || [];
+
+ print "$scope $type properties\n"
+ . "==================================\n" if !$from_command;
+ print ''.($scope eq 'Endpoint' ? +(split '\.', $$_{name})[-1] . ': ' : '')."$$_{value}\n" for @$settings;
+}
+
+sub integrator_global_property_whitelist_set {
+ return set_property_filter('whitelist');
+}
+
+sub integrator_global_property_blacklist_set {
+ return set_property_filter('blacklist');
+}
+
+sub integrator_global_property_whitelist_remove {
+ return remove_property_filter('whitelist');
+}
+
+sub integrator_global_property_blacklist_remove {
+ return remove_property_filter('blacklist');
+}
+
+sub find_property_filter_value {
+ my $name = shift;
+ my $setting = [grep { $$_{name} eq $name } @{$state{integrator}{filter_settings}}]->[0];
+ return $$setting{value} if $setting;
+ return undef;
+}
+
+sub set_property_filter {
+ my $type = shift;
+ my $endpoint = shift;
+
+ my $name = "REST.api.${type}_properties";
+ $name .= ".$endpoint" if $endpoint;
+
+ my $current_value = find_property_filter_value($name);
+
+ if (my $id = $state{integrator}{id} || integrator_load("Integrator username adjust ${type}ed properties:")) {
+ print "Current value of $name: $current_value\n" if $current_value;
+ my $new_value = value_or_argv("Comma separated list of properties to $type, [Enter] to leave unchanged:");
+ if ($new_value) {
+ $new_value =~ s/"//g;
+ $new_value = '"'.$new_value.'"';
+
+ # auto-vivicate the user setting type for endpoint-specific ones
+ $dbh->do(
+ 'INSERT INTO config.usr_setting_type (name,label,grp) VALUES (?,?,?) ON CONFLICT DO NOTHING;', undef,
+ $name,"OpenAPI Fieldmapper property $type for endpoint $endpoint",'openapi'
+ ) if $endpoint;
+
+ $dbh->do(
+ 'INSERT INTO actor.usr_setting (usr,name,value) VALUES (?,?,?) ON CONFLICT (usr,name) DO UPDATE SET value = ?;', undef,
+ $id,$name,$new_value,$new_value
+ );
+ reload_integrator();
+ }
+ }
+}
+
+sub remove_property_filter {
+ my $type = shift;
+ my $endpoint = shift;
+
+ my $name = "REST.api.${type}_properties";
+ $name .= ".$endpoint" if $endpoint;
+
+ my $current_value = find_property_filter_value($name);
+
+ if (my $id = $state{integrator}{id} || integrator_load("Integrator username to remove ${type}ed properties from:")->{id}) {
+ print "Current value of $name: $current_value\n" if $current_value;
+ if (yn("Continue to remove $type in setting $name for [$state{integrator}{usrname}]?")) {
+ $dbh->do('DELETE FROM actor.usr_setting WHERE usr = ? AND name = ?;', undef, $id, $name);
+ reload_integrator();
+ }
+ }
+}
+
+sub existing_endpoint_property_filter_endpoints {
+ return [] if !$state{integrator}{id};
+ return [
+ sort {
+ $a cmp $b
+ } map {
+ s/^REST\.api\.\w+?_properties\.//; $_;
+ } grep {
+ /^REST\.api\.\w+?_properties\./
+ } map {
+ $$_{name}
+ } @{$state{integrator}{filter_settings}}
+ ];
+}
+
+sub integrator_endpoint_property_whitelist {
+ my $usrname = $state{integrator}{usrname} || integrator_load()->{usrname};
+ next_cmd('Endpoint-specific Fieldmapper property whitelist actions:' => [qw/list add edit remove/]);
+}
+
+sub integrator_endpoint_property_blacklist {
+ my $usrname = $state{integrator}{usrname} || integrator_load()->{usrname};
+ next_cmd('Endpoint-specific Fieldmapper property blacklist actions:' => [qw/list add edit remove/]);
+}
+
+sub integrator_endpoint_property_whitelist_add {
+ return integrator_endpoint_property_filter_change(add => 'whitelist');
+}
+
+sub integrator_endpoint_property_blacklist_add {
+ return integrator_endpoint_property_filter_change(add => 'blacklist');
+}
+
+sub integrator_endpoint_property_whitelist_edit {
+ return integrator_endpoint_property_filter_change(edit => 'whitelist');
+}
+
+sub integrator_endpoint_property_blacklist_edit {
+ return integrator_endpoint_property_filter_change(edit => 'blacklist');
+}
+
+sub integrator_endpoint_property_whitelist_remove {
+ return integrator_endpoint_property_filter_change(remove => 'whitelist');
+}
+
+sub integrator_endpoint_property_blacklist_remove {
+ return integrator_endpoint_property_filter_change(remove => 'blacklist');
+}
+
+sub integrator_endpoint_property_filter_change {
+ my $action = shift;
+ my $type = shift;
+
+ if ($state{integrator}{usrname} || integrator_load("Integrator username to $action ${type}ed properties for:")) {
+ my $existing_endpoints = existing_endpoint_property_filter_endpoints();
+ if ($action eq 'add' or @$existing_endpoints) {
+ my $where = @$existing_endpoints ? "WHERE operation_id ".($action eq 'add' ? 'NOT' : '')." IN ('".join("','", @$existing_endpoints)."')" : '';
+ my $new_endpoint = number_menu_or_argv(
+ "Endpoint to $action $type:",
+ menu_spec_from_table("openapi.endpoint $where","operation_id || ': ' || http_method || ' ' || path",'operation_id','[Cancel]',0)
+ );
+ if ($new_endpoint ne '0') {
+ if ($action eq 'remove') {
+ remove_property_filter($type, $new_endpoint);
+ } else {
+ set_property_filter($type, $new_endpoint);
+ }
+ }
+ }
+ }
+}
+
+sub integrator_list {
+ print "Integrators:\n"
+ . "============\n" if !$from_command;
+ print join(
+ "\n",
+ map {
+ "$$_{usrname}: " . ($$_{enabled} ? 'Enabled' : 'Disabled')
+ } $dbh->selectall_array('SELECT u.usrname, i.enabled FROM actor.usr u JOIN openapi.integrator i USING (id) WHERE NOT u.deleted ORDER BY 1',{Slice => {}})
+ ) . "\n";
+}
+
+sub integrator_password {
+ if ($state{integrator} or integrator_load()) {
+ my $pw = pw_value("New API password for [$state{integrator}{usrname}]:");
+ my $pw2 = pw_value("Confirm new API password for [$state{integrator}{usrname}]:");
+
+ if ($pw ne $pw2) {
+ print " !! passwords don't match \n";
+ return;
+ }
+
+ if ($pw ne '' and yn("Continue to (re)set password for [$state{integrator}{usrname}]?")) {
+ my $salt = $dbh->selectcol_arrayref("SELECT actor.create_salt('api')")->[0];
+ $dbh->do("SELECT actor.set_passwd(?,'api',?,?)", undef, $state{integrator}{id}, md5_hex($salt . md5_hex($pw)), $salt);
+ }
+ } else {
+ print " !! Load integrator before (re)setting password\n";
+ }
+}
+
+sub integrator_add {
+ my $usrname = value_or_argv('New Integrator username:');
+ if ($usrname ne '') {
+ $dbh->do('INSERT INTO openapi.integrator (id) SELECT id FROM actor.usr WHERE usrname = ?', undef, $usrname);
+ unshift @ARGV, $usrname;
+ integrator_load();
+ }
+}
+
+sub integrator_remove {
+ my $usrname = $state{integrator}{usrname} || integrator_load('Integrator username to remove:')->{usrname};
+ if ($usrname ne '' and yn("Continue to remove integrator $usrname?")) {
+ $dbh->do('DELETE FROM openapi.integrator WHERE id IN (SELECT id FROM actor.usr WHERE usrname = ?)', undef, $usrname);
+ }
+}
+
+sub integrator_enable {
+ my $enabled = shift || 'TRUE';
+ my $usrname = $state{integrator}{usrname} || value_or_argv('Integrator username:');
+ if ($usrname ne '') {
+ $dbh->do("UPDATE openapi.integrator SET enabled = $enabled WHERE id IN (SELECT id FROM actor.usr WHERE usrname = ?)", undef, $usrname);
+ unshift @ARGV, $usrname;
+ integrator_load();
+ }
+}
+sub integrator_disable { integrator_enable('FALSE') }
+
+sub integrator_permissions {
+ my $usrname = $state{integrator}{usrname} || integrator_load()->{usrname};
+ next_cmd('Integrator permission actions:' => [qw/list add remove/])
+}
+
+sub integrator_permissions_list {
+ print "Permissions:\n"
+ . "============\n" if !$from_command;
+ print join("\n", map {$$_{code}} @{$state{integrator}{user_perms}}) . "\n\n";
+}
+
+sub integrator_permissions_add {
+ my $new_perm = list_or_argv('New Permission:' => $dbh->selectcol_arrayref('SELECT code FROM permission.perm_list'));
+ if ($new_perm ne '') {
+ my $new_depth = number_menu_or_argv('Permission Depth:' => depth_menu_spec());
+ if ($new_depth and yn("Add permission [$new_perm]?")) {
+ $new_perm = $dbh->selectcol_arrayref('SELECT id FROM permission.perm_list WHERE code = ?', undef, $new_perm)->[0];
+ $dbh->do('INSERT INTO permission.usr_perm_map (usr,perm,depth) VALUES (?,?,?)', undef, $state{integrator}{id}, $new_perm, $new_depth);
+ reload_integrator();
+ }
+ }
+}
+
+sub integrator_permissions_remove {
+ if (@{$state{integrator}{user_perms}}) {
+ my $perm = list_or_argv('Permission:' => [map {$$_{code}} @{$state{integrator}{user_perms}}]);
+ if ($perm ne '' and yn("Remove permission [$perm]?")) {
+ $perm = $dbh->selectcol_arrayref('SELECT id FROM permission.perm_list WHERE code = ?', undef, $perm)->[0];
+ $dbh->do('DELETE FROM permission.usr_perm_map WHERE usr = ? AND perm = ?', undef, $state{integrator}{id}, $perm) if ($perm);
+ reload_integrator();
+ }
+ }
+}
+
+sub integrator_groups {
+ my $usrname = $state{integrator}{usrname} || integrator_load()->{usrname};
+ next_cmd('Integrator secondary group actions:' => [qw/list add remove/])
+}
+
+sub integrator_groups_list {
+ print "Groups:\n"
+ . "=======\n" if !$from_command;
+ print join("\n", map {$$_{name}} @{$state{integrator}{secondary_groups}}) . "\n\n";
+}
+
+sub integrator_groups_add {
+ my $new_group = number_menu_or_argv('New Group:' => menu_spec_from_table('permission.grp_tree','name','id','[Cancel]' => 0));
+ my ($new_group_name) = $dbh->selectrow_array('SELECT name FROM permission.grp_tree WHERE id = ?', undef, $new_group);
+ if ($new_group ne '0' and yn("Add Secondary Group [$new_group_name]?")) {
+ $dbh->do('INSERT INTO permission.usr_grp_map (usr,grp) VALUES (?,?)', undef, $state{integrator}{id}, $new_group);
+ reload_integrator();
+ }
+}
+
+sub integrator_groups_remove {
+ if (@{$state{integrator}{secondary_groups}}) {
+ my $group = number_menu_or_argv('Group:' => {' [Cancel]' => 0, map { $$_{name} => $$_{id} } @{$state{integrator}{secondary_groups}}});
+ my ($group_name) = $dbh->selectrow_array('SELECT name FROM permission.grp_tree WHERE id = ?', undef, $group);
+ if ($group ne '0' and yn("Remove Secondary Group [$group_name]?")) {
+ $dbh->do('DELETE FROM permission.usr_grp_map WHERE usr = ? AND grp = ?', undef, $state{integrator}{id}, $group);
+ reload_integrator();
+ }
+ }
+}
+
+sub integrator_details {
+ $state{integrator_details} = $state{integrator_details} ? 0 : 1;
+ print ' ! Integrator Detail Dispaly: '. ($state{integrator_details} ? 'On' : 'Off'). "\n" if !$from_command;
+}
+
+sub integrator_unload {
+ delete $state{integrator};
+}
+
+sub reload_integrator {
+ if ($state{integrator}{usrname}) {
+ unshift @ARGV, $state{integrator}{usrname};
+ integrator_load();
+ }
+}
+
+sub integrator_load {
+ my $prompt = shift || 'Integrator username:';
+ $state{integrator} = $dbh->selectrow_hashref(
+ 'SELECT u.*, i.enabled AS api_enabled FROM actor.usr u JOIN openapi.integrator i USING (id) WHERE NOT u.deleted AND u.usrname = ?',
+ undef, value_or_argv($prompt)
+ );
+
+ if (!$state{integrator}) {
+ print " ! Integrator account not found\n";
+ return undef;
+ }
+
+ $state{integrator}{profile} = $dbh->selectrow_hashref(
+ 'SELECT * FROM permission.grp_tree WHERE id = ?',
+ undef, $state{integrator}{profile}
+ );
+
+ $state{integrator}{secondary_groups} = $dbh->selectall_arrayref(
+ 'SELECT g.* FROM permission.grp_tree g JOIN permission.usr_grp_map m ON (m.grp = g.id) WHERE m.usr = ?',
+ { Slice => {} }, $state{integrator}{id}
+ );
+
+ $state{integrator}{filter_settings} = $dbh->selectall_arrayref(
+ "SELECT * FROM actor.usr_setting WHERE name like 'REST.api.%properties%' AND usr = ? ORDER BY name",
+ { Slice => {} }, $state{integrator}{id}
+ );
+
+ $state{integrator}{endpoint_rate_limits} = $dbh->selectall_arrayref(
+ 'SELECT m.endpoint, r.* FROM openapi.endpoint_user_rate_limit_map m JOIN openapi.rate_limit_definition r ON (r.id = m.rate_limit) WHERE m.accessor = ?',
+ { Slice => {} }, $state{integrator}{id}
+ );
+
+ $state{integrator}{endpoint_set_rate_limits} = $dbh->selectall_arrayref(
+ 'SELECT m.endpoint_set, r.* FROM openapi.endpoint_set_user_rate_limit_map m JOIN openapi.rate_limit_definition r ON (r.id = m.rate_limit) WHERE m.accessor = ?',
+ { Slice => {} }, $state{integrator}{id}
+ );
+
+ $state{integrator}{login_attempt_summary} = $dbh->selectrow_hashref(
+ 'SELECT MIN(attempt_time) AS first_attempt,'.
+ ' MAX(attempt_time) AS last_attempt,'.
+ ' SUM((token IS NOT NULL)::INT) AS success,'.
+ " SUM((NULLIF(token,'') IS NULL)::INT) AS failure".
+ ' FROM openapi.authen_attempt_log'.
+ ' WHERE cred_user = ?'.
+ ' GROUP BY cred_user',
+ undef, $state{integrator}{usrname}
+ );
+
+ $state{integrator}{endpoint_access_summary} = $dbh->selectall_arrayref(
+ 'SELECT endpoint,'.
+ ' MIN(attempt_time) AS first_attempt,'.
+ ' MAX(attempt_time) AS last_attempt,'.
+ ' SUM(allowed::INT) AS success,'.
+ ' SUM((allowed IS FALSE)::INT) AS failure'.
+ ' FROM openapi.endpoint_access_attempt_log'.
+ ' WHERE accessor = ?'.
+ ' GROUP BY endpoint',
+ { Slice => {} }, $state{integrator}{id}
+ );
+
+ $state{integrator}{user_perms} = $dbh->selectall_arrayref(
+ 'SELECT p.* FROM permission.perm_list p JOIN permission.usr_perm_map m ON (m.perm = p.id) WHERE m.usr = ?',
+ { Slice => {} }, $state{integrator}{id}
+ );
+
+ $state{integrator}{card} = $dbh->selectrow_hashref(
+ 'SELECT * FROM actor.card WHERE id = ?',
+ undef, $state{integrator}{card}
+ ) if $state{integrator}{card};
+
+ return $state{integrator};
+}
+
+sub api_endpoints_unload {
+ delete $state{api}{endpoint};
+}
+
+sub reload_api_endpoints {
+ if ($state{api}{endpoint}{operation_id}) {
+ unshift @ARGV, $state{api}{endpoint}{operation_id};
+ api_endpoints_load();
+ }
+}
+
+sub api_endpoints_load {
+ my $prompt = shift || 'API Endpoint Operation Id:';
+ $state{api}{endpoint} = $dbh->selectrow_hashref(
+ 'SELECT * FROM openapi.endpoint WHERE operation_id = ?',
+ undef, number_menu_or_argv(
+ $prompt => menu_spec_from_table('openapi.endpoint',"http_method || ' ' || path",'operation_id','[Cancel]',0)
+ )
+ );
+
+ if (!$state{api}{endpoint}) {
+ print " ! API Endpoint not found\n";
+ return undef;
+ }
+
+ $state{api}{endpoint}{ip_ranges} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_ip_rate_limit_map WHERE endpoint = ?',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+
+ $state{api}{endpoint}{user_rate_limits} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_user_rate_limit_map WHERE endpoint = ?',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+
+ $state{api}{endpoint}{rate_limit} = $dbh->selectrow_hashref(
+ 'SELECT * FROM openapi.rate_limit_definition WHERE id = ?',
+ undef, $state{api}{endpoint}{rate_limit}
+ ) if $state{api}{endpoint}{rate_limit};
+
+ $state{api}{endpoint}{params} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_param WHERE endpoint = ?',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+
+ $state{api}{endpoint}{responses} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_response WHERE endpoint = ?',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+
+ $state{api}{endpoint}{sets} = $dbh->selectcol_arrayref(
+ 'SELECT endpoint_set FROM openapi.endpoint_set_endpoint_map WHERE endpoint = ?',
+ undef, $state{api}{endpoint}{operation_id}
+ );
+
+ $state{api}{endpoint}{perms} = $dbh->selectall_arrayref(
+ 'SELECT p.* FROM permission.perm_list p JOIN openapi.endpoint_perm_map m ON (m.perm = p.id) WHERE m.endpoint = ?',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+
+ $state{api}{endpoint}{perm_sets} = $dbh->selectall_arrayref(
+ 'SELECT s.* FROM openapi.perm_set s JOIN openapi.endpoint_perm_set_map m ON (m.perm_set = s.id) WHERE m.endpoint = ?',
+ { Slice => {} }, $state{api}{endpoint}{operation_id}
+ );
+
+ for my $ps ( @{$state{api}{endpoint}{perm_sets}} ) {
+ $$ps{perms} = $dbh->selectall_arrayref(
+ 'SELECT p.* FROM permission.perm_list p JOIN openapi.perm_set_perm_map m ON (m.perm = p.id) WHERE m.perm_set = ?',
+ { Slice => {} }, $$ps{id}
+ );
+ }
+
+ return $state{api}{endpoint};
+}
+
+sub api_sets_unload {
+ delete $state{api}{set};
+}
+
+sub reload_api_sets {
+ if ($state{api}{set}{name}) {
+ unshift @ARGV, $state{api}{set}{name};
+ api_sets_load();
+ }
+}
+
+sub api_sets_load {
+ my $prompt = shift || 'API Endpoint Set Name:';
+ $state{api}{set} = $dbh->selectrow_hashref(
+ 'SELECT * FROM openapi.endpoint_set WHERE name = ?',
+ undef, number_menu_or_argv(
+ $prompt => menu_spec_from_table('openapi.endpoint_set',"description",'name','[Cancel]',0)
+ )
+ );
+
+ if (!$state{api}{set}) {
+ print " ! API Endpoint Set not found\n";
+ return undef;
+ }
+
+ $state{api}{set}{user_rate_limits} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_set_user_rate_limit_map WHERE endpoint_set = ?',
+ { Slice => {} }, $state{api}{set}{name}
+ );
+
+ $state{api}{set}{ip_ranges} = $dbh->selectall_arrayref(
+ 'SELECT * FROM openapi.endpoint_set_ip_rate_limit_map WHERE endpoint_set = ?',
+ { Slice => {} }, $state{api}{set}{name}
+ );
+
+ $state{api}{set}{rate_limit} = $dbh->selectrow_hashref(
+ 'SELECT * FROM openapi.rate_limit_definition WHERE id = ?',
+ undef, $state{api}{set}{rate_limit}
+ ) if $state{api}{set}{rate_limit};
+
+ $state{api}{set}{endpoints} = $dbh->selectcol_arrayref(
+ 'SELECT endpoint FROM openapi.endpoint_set_endpoint_map WHERE endpoint_set = ?',
+ undef, $state{api}{set}{name}
+ );
+
+ $state{api}{set}{perms} = $dbh->selectall_arrayref(
+ 'SELECT p.* FROM permission.perm_list p JOIN openapi.endpoint_set_perm_map m ON (m.perm = p.id) WHERE m.endpoint_set = ?',
+ { Slice => {} }, $state{api}{set}{name}
+ );
+
+ $state{api}{set}{perm_sets} = $dbh->selectall_arrayref(
+ 'SELECT s.* FROM openapi.perm_set s JOIN openapi.endpoint_set_perm_set_map m ON (m.perm_set = s.id) WHERE m.endpoint_set = ?',
+ { Slice => {} }, $state{api}{set}{name}
+ );
+
+ for my $ps ( @{$state{api}{set}{perm_sets}} ) {
+ $$ps{perms} = $dbh->selectall_arrayref(
+ 'SELECT p.* FROM permission.perm_list p JOIN openapi.perm_set_perm_map m ON (m.perm = p.id) WHERE m.perm_set = ?',
+ { Slice => {} }, $$ps{id}
+ );
+ }
+
+ return $state{api}{set};
+}
+
diff --git a/Open-ILS/src/support-scripts/openapi_server b/Open-ILS/src/support-scripts/openapi_server
new file mode 100755
index 0000000000..a7e2d0567e
--- /dev/null
+++ b/Open-ILS/src/support-scripts/openapi_server
@@ -0,0 +1,1016 @@
+#!/usr/bin/perl
+
+# ---------------------------------------------------------------
+# Copyright (C) 2024 Equinox Open Library Initiative, Inc.
+# Mike Rylander <mrylander(a)gmail.com>
+# Galen Charlton <gmc(a)equinoxOLI.org>
+
+# This program 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 2
+# of the License, or (at your option) any later version.
+
+# This program 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.
+# ---------------------------------------------------------------
+
+# Add an nginx config block to the https section:
+#
+# location /openapi3 {
+# proxy_pass https://localhost:8080;
+# proxy_set_header Host $host;
+# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+# proxy_set_header X-Forwarded-Proto $scheme;
+# proxy_read_timeout 300s;
+# }
+#
+#
+# And start it up like this:
+#
+# /openils/bin/openapi_server prefork -m production -l https://localhost:8080
+#
+#
+# For server option details, see: https://docs.mojolicious.org/Mojolicious/Guides/Cookbook#DEPLOYMENT
+#
+
+
+use strict;
+use warnings;
+
+my $U = "OpenILS::Application::AppUtils";
+
+package OpenILS::APIServer;
+
+use MIME::Base64;
+use Scalar::Util qw/blessed/;
+use Types::Serialiser;
+use Mojolicious::Lite;
+use OpenSRF::System;
+use OpenSRF::AppSession;
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::EX qw(:try);
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor q/new_editor/;
+use OpenSRF::Utils::Logger q/$logger/;
+use Data::Dumper;
+
+#-------------------------------------------------
+# globals and setup
+
+my $osrf_config = '/openils/conf/opensrf_core.xml';
+my $rev_proxy_levels = $ENV{EG_REV_PROXY_LEVELS} // 1;
+
+OpenSRF::System->bootstrap_client(config_file => $osrf_config);
+
+Fieldmapper->import(
+ IDL => OpenSRF::Utils::SettingsClient->new->config_value("IDL")
+);
+
+OpenILS::Utils::CStoreEditor->init;
+
+#-------------------------------------------------
+# Build the OpenAPI 3 config blob
+
+my $config = {
+ openapi => "3.0.3",
+ info => {
+ description => "RESTful API for the Evergreen ILS",
+ version => "1.0.0",
+ title => "Evergreen API",
+ license => {
+ name => "GNU Public License 2.0+"
+ }
+ },
+ servers => [
+ { url => "/openapi3/v1" }
+ ],
+ tags => [],
+ components => {
+ securitySchemes => {
+ basicAuth => { # /only/ used to get a token, handler does the actual auth and the plugin sub is just a passthrough
+ type => "http",
+ scheme => "basic"
+ },
+ passthroughUser => { # /only/ used to get a token, handler does the actual auth and the plugin sub is just a passthrough
+ type => "apiKey",
+ in => "query",
+ name => "u"
+ },
+ passthroughPass => { # /only/ used to get a token, handler does the actual auth and the plugin sub is just a passthrough
+ type => "apiKey",
+ in => "query",
+ name => "p"
+ },
+ bearerAuth => {
+ type => "http",
+ scheme => "bearer"
+ },
+ cookieAuth => {
+ type => "apiKey",
+ in => "cookie",
+ name => "eg.api.token"
+ },
+ paramAuth => {
+ type => "apiKey",
+ in => "query",
+ name => "ses"
+ }
+ },
+ schemas => generate_schemas()
+ }
+};
+
+# gather tags (endpoint sets)...
+add_endpoint_sets();
+
+# ... and, finally, gather the registered paths ...
+add_dynamic_endpoints();
+
+#-------------------------------------------------
+# Finally, let the openapi plugin do it's thing
+
+plugin OpenAPI => {
+ #skip_validating_specification => 1,
+ spec => JSON::Validator::Schema::OpenAPIv3::Evergreen->new($config),
+ security => {
+ basicAuth => sub { return $_[3]->($_[0]) }, # basic auth is only used for initial login
+ bearerAuth => sub { return securityCheck(bearerAuth => @_) },
+ cookieAuth => sub { return securityCheck(cookieAuth => @_) },
+ paramAuth => sub { return securityCheck(paramAuth => @_) }
+ },
+};
+
+# We should be behind a reverse proxy, probably nginx. Set the
+# env var EG_REV_PROXY_LEVELS to the depth of the reverse proxy
+# stack that YOU control. Theoretically, that could even be "0"
+# if you were to expose this API server directly to clients.
+plugin ForwardedFor => { levels => $rev_proxy_levels };
+
+#-------------------------------------------------
+# Almost ready to go...
+
+app->hook(after_dispatch => sub { log_authenticated_dispatch_event(@_) });
+app->hook(after_dispatch => sub { $_[0]->res->headers->remove('Server') });
+
+# XXX If we want to use session cookies we'll need to come
+# up with a better way to set secrets().
+app->secrets(scalar time)->start;
+
+
+
+
+
+
+
+#-------------------------------------------------
+# Support functions
+
+sub add_dynamic_endpoints {
+ my $paths = new_editor()->json_query({
+ distinct => 1,
+ select => { oep => ['path'] },
+ from => 'oep',
+ where => { active => 't' }
+ });
+
+ # ... and register all their HTTP method handlers ...
+ for my $path (map {$$_{path}} @$paths) {
+ $logger->info("Loading path configuration for: $path");
+ my $path_config = fetch_path_config($path);
+ add_path($path => $_ => @{$$path_config{$_}}) for keys %$path_config;
+ }
+}
+
+sub add_endpoint_sets {
+ my $tags = new_editor()->search_openapi_endpoint_set({ active => 't' });
+ $logger->info(
+ "Loading endpoint sets (OpenAPI tags): ".join(' ', map { $_->name} @$tags)
+ ) if @$tags;
+
+ $$config{tags} ||= [];
+ for my $tag (@$tags) {
+ if (!grep {$$_{name} eq $tag->name} @{$$config{tags}}) {
+ push @{$$config{tags}}, {name => $tag->name, description => $tag->description};
+ }
+ }
+}
+
+sub _build_handler_params {
+ my $c = shift;
+ my $pdef = shift || [];
+
+ my @params;
+ for my $part (@$pdef) {
+ if ($part =~ /^(["'])(.*?)\1$/) { # a quoted literal? just pass it along
+ push @params, $2;
+ next;
+ }
+
+ my $c_method = 'stash'; # assume stash for unqualified part names
+ my @subparts = split /\./, $part;
+
+ # useful prefixes: stash [every_]param [every_|signed_]cookie req session
+ if (scalar(@subparts) > 1) { # has a .?
+ $c_method = shift @subparts;
+ }
+
+ # req needs a little shoving around to be useful. the others
+ # (generally) are generalized accessor/mutator objects, req
+ # is an object with methods we want to call.
+ my $context_obj = $c;
+ if ($c_method eq 'req') {
+ $c_method = shift @subparts;
+ $context_obj = $c->req;
+ }
+
+ push @params, $context_obj->$c_method(@subparts);
+ }
+
+ return @params;
+}
+
+# returns renderer handler name, sets $c->stash('eg_req_resolved_content_type'), and might set $c->stash('format')
+sub resolve_requested_render_type {
+ my $c = shift;
+ my $endpoint = shift;
+
+ my $validated = 0;
+ my @resp_types = qw#application/json#;
+
+ if (!ref($endpoint)) {
+ $validated = $endpoint;
+ } else {
+ $validated = grep { $U->is_true($_->validate) and $_->status == 200 } @{$endpoint->responses};
+ @resp_types = map { $_->content_type } @{$endpoint->responses};
+ }
+
+ my %default_tokens;
+ if (my $accept_tokens = $c->req->headers->header('X-EG-OpenAPI-Options')) {
+ $accept_tokens =~ s/^\s+|\s+$//g;
+ $c->app->log->trace("Received options [$accept_tokens] via header [X-EG-OpenAPI-Options] operationId: ".$c->stash('operationId'));
+
+ my @parts = split ';', $accept_tokens;
+ for my $part (@parts) {
+ my ($token_key, $token_value) = split '=', $part;
+ if (defined($token_key) and defined($token_value)) {
+ $token_key =~ s/^\s+|\s+$//g;
+ $token_value =~ s/^\s+|\s+$//g;
+ $default_tokens{$token_key} = $token_value;
+ $c->app->log->trace("Adding default Accept header option [$token_key] with value [$token_value] via header [X-EG-OpenAPI-Options] for operationId: ".$c->stash('operationId'));
+ }
+ }
+ }
+
+ my $hdr = $c->req->headers->accept || '';
+ $hdr =~ s/^\s+|\s+$//g;
+
+ unless ($hdr) { # assume JSON, maybe validated
+ return $validated ? 'openapi' : 'json';
+ }
+
+ my @opt_request_list = split ',', $hdr;
+ my $default_order_val = scalar @opt_request_list;
+
+ my @all_opts;
+ for my $option (@opt_request_list) {
+ my $specificity_qual = 0;
+ my $order_qual = $default_order_val--;
+ my $user_qual = 1.0;
+ my $tokens = { map {($_ => $default_tokens{$_})} keys %default_tokens };
+
+ $option =~ s/^\s+|\s+$//g;
+
+ my ($type, @other) = split ';', $option;
+ $type =~ s/^\s+|\s+$//g;
+
+ my ($main_type, $sub_type) = split '/', $type;
+ $specificity_qual++ if ($main_type ne '*');
+ $specificity_qual++ if ($sub_type ne '*');
+
+ for my $maybe_qual (@other) {
+ $maybe_qual =~ s/^\s+|\s+$//g;
+
+ my ($token_key, $token_value) = split '=', $maybe_qual;
+ if (defined($token_key) and defined($token_value)) {
+ $token_key =~ s/^\s+|\s+$//g;
+ $token_value =~ s/^\s+|\s+$//g;
+ $$tokens{$token_key} = $token_value;
+ }
+ }
+
+ # we only use the q=$weight part, but record the rest, I guess
+ $user_qual *= $$tokens{q}
+ if (exists($$tokens{q}) and $$tokens{q} =~ /^\d+(?:\.\d+)?$/);
+
+ push @all_opts, {
+ full_type => $type,
+ main_type => $main_type,
+ sub_type => $sub_type,
+ tokens => $tokens,
+ oqual => $order_qual,
+ squal => $specificity_qual,
+ uqual => $user_qual
+ };
+ }
+
+ @all_opts = sort { # sort on weight/specificity/order
+ $$b{uqual} <=> $$a{uqual} || # order they asked for via q-metric, larger is first
+ $$b{squal} <=> $$a{squal} || # specific rather than wildcard, more specific (larger) is first
+ $$b{oqual} <=> $$a{oqual} # order specified by the user; counts down, so larger is first
+ } @all_opts;
+
+ $c->stash(eg_req_accepts => \@all_opts);
+
+ my ($have_xml) = grep { /xml$/ } @resp_types;
+ my ($have_json) = grep { /json$/ } @resp_types;
+ my ($have_html) = grep { /^text\/html$/ } @resp_types;
+ my ($have_text) = grep { /^text\/plain$/ } @resp_types;
+ my ($have_binary) = grep { /octet/ } @resp_types;
+
+ for my $req_opt (@all_opts) {
+ my $want_xml = ($$req_opt{sub_type} =~ /xml$/) ? 1 : 0;
+ my $want_json = ($$req_opt{sub_type} =~ /json$/) ? 1 : 0;
+ my $want_html = ($$req_opt{full_type} eq 'text/html') ? 1 : 0;
+ my $want_text = ($$req_opt{main_type} =~ /^text/) ? 1 : 0;
+ my $want_binary = ($$req_opt{sub_type} =~ /octet/) ? 1 : 0;
+
+ if ($want_xml and $have_xml) {
+ $c->res->headers->content_type($have_xml);
+ $c->stash(format => 'xml');
+ $c->stash(eg_req_resolved_content_type_object => $req_opt);
+ $c->stash(eg_req_resolved_content_format => 'xml');
+ $c->stash(eg_req_resolved_content_renderer => 'text');
+ } elsif ($want_json and $have_json) {
+ $c->res->headers->content_type($have_json);
+ $c->stash(eg_req_resolved_content_type_object => $req_opt);
+ $c->stash(eg_req_resolved_content_format => 'json');
+ $c->stash(eg_req_resolved_content_renderer => $validated ? 'openapi' : 'json');
+ } elsif ($want_html and $have_html) {
+ $c->res->headers->content_type($have_html);
+ $c->stash(format => 'html');
+ $c->stash(eg_req_resolved_content_type_object => $req_opt);
+ $c->stash(eg_req_resolved_content_format => 'html');
+ $c->stash(eg_req_resolved_content_renderer => 'html');
+ } elsif ($want_text and $have_text) {
+ $c->res->headers->content_type($have_text);
+ $c->stash(eg_req_resolved_content_type_object => $req_opt);
+ $c->stash(eg_req_resolved_content_format => 'text');
+ $c->stash(eg_req_resolved_content_renderer => 'text');
+ } elsif ($want_binary and $have_binary) {
+ $c->res->headers->content_type($have_binary);
+ $c->stash(eg_req_resolved_content_type_object => $req_opt);
+ $c->stash(eg_req_resolved_content_format => 'binary');
+ $c->stash(eg_req_resolved_content_renderer => 'data');
+ }
+
+ return $c->stash('eg_req_resolved_content_renderer')
+ if ($c->stash('eg_req_resolved_content_renderer'));
+ }
+
+ # push the default tokens from the X-EG-OpenAPI-Options header into the environment
+ $c->stash(eg_req_resolved_content_type_object => { tokens => \%default_tokens });
+
+ # found nothing matching above, we'll just use a default
+ return $validated ? 'openapi' : 'json';
+}
+
+sub automated_handler_wrapper {
+ my $endpoint = shift;
+ my $param_text = $endpoint->method_params || '';
+ my $plist = [grep {$_} split /\s+/, $param_text];
+
+ # opensrf app names contain .s
+ return opensrf_passthrough_wrapper( $endpoint->method_source, $endpoint->method_name, $plist, $endpoint )
+ if ($endpoint->method_source =~ /\./);
+
+ # must be an MJ controller method
+ my $pkg = $endpoint->method_source;
+
+ my $built_in = $pkg.'::VERSION';
+ { no strict 'refs';
+ # check to see if we already have the namespace, somehow
+ unless (eval '$'.$built_in) {
+ $pkg->use or die $@;
+ }
+ }
+
+ my $code = $pkg . '::' . $endpoint->method_name;
+ return basic_handler_wrapper($plist => $code => $endpoint);
+}
+
+sub is_rendered_success_type {
+ my $type = shift;
+ my $ep = shift;
+ my $renderer = shift;
+ return 0 unless ( grep {$renderer eq $_} qw/json openapi/ );
+
+ my ($success_res) = grep {$_->status == 200} @{$ep->responses};
+ return 0 unless $success_res;
+
+ return $success_res->schema_type eq $type ? 1 : 0;
+}
+
+sub basic_handler_wrapper {
+ my $stash_parts = shift;
+ my $code = shift;
+ my $endpoint = shift;
+
+ return sub {
+ my $c = shift->openapi->valid_input or return;
+ apply_locale($c);
+
+ my $renderer = resolve_requested_render_type($c, $endpoint);
+ my @params = _build_handler_params($c, $stash_parts);
+
+ my $result;
+ try {
+ if (ref($code) and ref($code) eq 'CODE') { # code ref, run it directly
+ $result = $code->( $c, @params );
+ } else { # not a code ref, must be a package-qualified function name, call it
+ no strict 'refs';
+ $result = &{$code}($c, @params );
+ }
+ } catch Error with {
+ my $e = shift;
+ $result = { error => $e };
+ $c->res->code(500);
+ };
+
+
+ if ($c->res->is_error) {
+ $renderer = ref($result) ? 'json' : 'text';
+ $result //= '';
+ } elsif (!ref($result) and is_rendered_success_type(boolean => $endpoint, $renderer)) {
+ $result = JSON_bool($result);
+ } elsif (!ref($result) and is_rendered_success_type(integer => $endpoint, $renderer)) {
+ $result = 0+$result;
+ } elsif (!ref($result) and is_rendered_success_type(number => $endpoint, $renderer)) {
+ $result = 0.0+$result;
+ } elsif (!ref($result) and is_rendered_success_type(string => $endpoint, $renderer)) {
+ $result = ''.$result;
+ }
+
+ return $c->render( $renderer => to_bare_mixed_ref( $result, $c ) );
+ };
+}
+
+sub opensrf_passthrough_wrapper {
+ my $service = shift;
+ my $method = shift;
+ my $stash_parts = shift;
+ my $endpoint = shift;
+
+ return sub {
+ my $c = shift->openapi->valid_input or return;
+ apply_locale($c);
+
+ my $renderer = resolve_requested_render_type($c, $endpoint);
+
+ my $result;
+
+ try {
+ $result = $U->simplereq(
+ $service => $method,
+ _build_handler_params($c, $stash_parts)
+ );
+
+ # TODO: Some OpenSRF methods only communicate ILS Events, so
+ # this may need to be thought through a bit before enabling.
+ #
+ # throw $result if ($U->is_event($result) and $result->{textcode} ne 'SUCCESS');
+
+ } catch Error with {
+ my $e = shift;
+ $result = { error => $e };
+ $c->res->code(500);
+ };
+
+ if ($c->res->is_error) {
+ $renderer = ref($result) ? 'json' : 'text';
+ $result //= '';
+ } elsif (!ref($result) and is_rendered_success_type(boolean => $endpoint, $renderer)) {
+ $result = JSON_bool($result);
+ } elsif (!ref($result) and is_rendered_success_type(integer => $endpoint, $renderer)) {
+ $result = 0+$result;
+ } elsif (!ref($result) and is_rendered_success_type(number => $endpoint, $renderer)) {
+ $result = 0.0+$result;
+ } elsif (!ref($result) and is_rendered_success_type(string => $endpoint, $renderer)) {
+ $result = ''.$result;
+ }
+ return $c->render( $renderer => to_bare_mixed_ref( $result, $c ) );
+ };
+}
+
+sub construct_subschema {
+ my $thing = shift; # fm object from openapi.endpoint_param or openapi.endpoint_response
+
+ my $new_subschema = {};
+ my $working = $new_subschema;
+
+ my $stype = $thing->schema_type;
+ if ($stype and $stype eq 'array') {
+ $$working{type} = 'array';
+ $$working{items} = {};
+ $working = $$working{items};
+ $stype = $thing->array_items;
+ } elsif ($thing->can('default_value')) {
+ $$working{default} = $thing->default_value if $thing->default_value;
+ }
+
+ if ($thing->fm_type) {
+ $$working{'$ref'} = '#/components/schemas/'.$thing->fm_type;
+ } elsif ($stype) {
+ $$working{type} = $stype;
+ $$working{format} = $thing->schema_format if ($thing->schema_format);
+ } else { # no fm_type, no schema_type, allow anything
+ # See: https://swagger.io/docs/specification/v3_0/data-models/data-types/#any-type and schema def
+ $$working{'$ref'} = '#/components/schemas/AnyValue';
+ }
+
+ return $new_subschema;
+}
+
+sub JSON_bool {
+ my $val = shift;
+ return $U->is_true($val) ? Types::Serialiser::true : Types::Serialiser::false;
+}
+
+our %_path_config_cache;
+sub fetch_path_config {
+ my $path = shift;
+ my $force = shift;
+
+ if (defined($_path_config_cache{$path}) and !$force) {
+ return $_path_config_cache{$path};
+ }
+
+ my $e = new_editor();
+ my $path_configs = $e->search_openapi_endpoint([
+ {path => $path, active => 't'},
+ {flesh => 3,
+ flesh_fields =>
+ { oep => [qw/perms perm_sets endpoint_sets parameters responses/], # openapi.endpoint
+ oes => [qw/perms perm_sets/], # openapi.endpoint_set
+ ops => [qw/perms/] # openapi.perm_set
+ }
+ }
+ ]);
+
+ $_path_config_cache{$path} = {}; # reset it on new or forced reload
+ for my $op ( @$path_configs ) {
+ my $p = $_path_config_cache{$path}{$op->http_method} = {
+ operationId => $op->operation_id,
+ summary => $op->summary,
+ tags => [map {$_->name} grep {$_->active eq 't'} @{$op->endpoint_sets}],
+ };
+
+ $$p{parameters} = [
+ map {{ required => JSON_bool($_->required), name => $_->name, in => $_->in_part, schema => construct_subschema($_) }} @{$op->parameters}
+ ] if @{$op->parameters};
+
+ # gather groups of permissions
+ my @perm_defs;
+
+ push @perm_defs, map {$_->code} @{$op->perms}; # perms assigned to the operationId, each allows access by itself
+ for my $pset (@{$op->perm_sets}) {
+ push @perm_defs, join(',', map {$_->code} @{$pset->perms}); # perms for each directly attached perm set, all are required for access
+ }
+ for my $eset (@{$op->endpoint_sets}) {
+ push @perm_defs, map {$_->code} @{$op->perms}; # perms assigned to an endpoint_set (tag) that this operationId belongs to, each allows access by itself
+ for my $pset (@{$eset->perm_sets}) {
+ push @perm_defs, join(',', map {$_->code} @{$pset->perms}); # perms for each endpoint_set attached perm set, all are required for access
+ }
+ }
+
+ # Wire up the perms we found
+ $$p{security} = [{$op->security => ['operationId:'.$op->operation_id, @perm_defs]}];
+
+ $$p{responses} = {};
+ for my $res (@{$op->responses}) {
+ $$p{responses}{$res->status} ||= {
+ description => $res->description,
+ content => {}
+ };
+ $$p{responses}{$res->status}{content}{$res->content_type} = {
+ schema => construct_subschema($res)
+ };
+ }
+
+ # Provide a sane success response schema, even though it won't be validated
+ $$p{responses}{200} ||= {
+ description => 'Success',
+ content => {
+ 'application/json' => {
+ schema => { '$ref' => '#/components/schemas/AnyValue' }
+ }
+ }
+ };
+
+ $_path_config_cache{$path}{$op->http_method} = [$p => automated_handler_wrapper($op)];
+ }
+
+ return $_path_config_cache{$path};
+}
+
+sub add_path {
+ my $path = shift;
+ (my $oapath = $path) =~ s/:(\w+)/{$1}/g;
+
+ while (@_) {
+ my $method = shift;
+ my $def= shift;
+ my $handler = shift;
+
+ $method = [$method] unless ref $method;
+
+ for my $m (@$method) {
+ my $m_def = { %$def };
+
+ # We only disambiguate when multiple HTTP methods
+ # are registered with one operation id. The in-db
+ # config cannot do that, so it's just some builtins
+ # and early prototyping methods.
+ $$m_def{operationId} .= "_$m" if @$method > 1;
+
+ unless (exists $$config{paths}{$oapath}{$m}) {
+ $$config{paths}{$oapath}{$m} = $m_def;
+ $m = 'del' if ($m eq 'delete'); # for registration via del()
+
+ my $defaults = {
+ operationId => $$m_def{operationId},
+ log_error => 0,
+ log_allowed => 0
+ };
+
+ &{\&{$m}}( # get, post, put, patch, del, etc, from MJ::L
+ $path => $defaults => $handler => $$m_def{operationId}
+ );
+ }
+ }
+ }
+}
+
+sub _t_f {
+ my $bool = shift;
+ return 'f' if ($bool and $bool eq 'f');
+ return !!$bool ? 't' : 'f';
+}
+
+sub log_authentication_attempt {
+ my $c = shift;
+ my $user = shift;
+ my $token = shift;
+
+ my $authen_attempt_log = Fieldmapper::openapi::authen_attempt_log->new;
+ $authen_attempt_log->request_id( $c->req->request_id );
+ $authen_attempt_log->ip_addr( $c->forwarded_for );
+ $authen_attempt_log->cred_user( $user );
+ $authen_attempt_log->token( $token );
+
+ my $e = new_editor(xact=>1);
+ $e->create_openapi_authen_attempt_log($authen_attempt_log);
+ $e->commit;
+}
+
+sub log_authenticated_dispatch_event {
+ my $c = shift;
+
+ my $oep_dispatch_log = Fieldmapper::openapi::endpoint_dispatch_log->new;
+ $oep_dispatch_log->request_id( $c->req->request_id );
+ $oep_dispatch_log->error( _t_f($c->stash('log_error')) );
+
+ my $e = new_editor(xact=>1);
+ $e->create_openapi_endpoint_dispatch_log($oep_dispatch_log);
+ $e->commit;
+}
+
+sub log_authenticated_endpoint_access_attempt_event {
+ my $c = shift;
+
+ my $oep_access_log = Fieldmapper::openapi::endpoint_access_attempt_log->new;
+ $oep_access_log->request_id( $c->req->request_id );
+ $oep_access_log->token( $c->stash('eg_auth_token') );
+ $oep_access_log->ip_addr( $c->forwarded_for );
+ $oep_access_log->accessor( $c->stash('eg_user_id') );
+ $oep_access_log->endpoint( $c->stash('operationId') );
+ $oep_access_log->allowed( _t_f($c->stash('log_allowed')) );
+
+ my $e = new_editor(xact=>1);
+ $e->create_openapi_endpoint_access_attempt_log($oep_access_log);
+ $e->commit;
+}
+
+sub securityCheck {
+ my ($type, $c, $definition, $scopes, $cb) = @_;
+ $scopes = ref($scopes) ? [@$scopes] : [$scopes]; # make a copy, and make it an arrayref
+
+ if ($$scopes[0] =~ /^operationId:(\S+)/) { # special scope value marker for passing OpId to the under() handler
+ $c->stash(operationId => $1);
+ shift @$scopes
+ }
+
+ return $cb->($c) if ($U->is_true($c->stash('log_allowed'))); # already successfully completed, move on
+
+ my $fail_message = 'Request not allowed'; # generic error message
+ $c->app->log->trace("Security check [$type] for operationId: ".$c->stash('operationId'));
+
+ my $ses = [split ' ', $c->req->headers->authorization // '']->[-1]
+ || $c->cookie('eg.api.token')
+ || $c->cookie('eg.auth.token')
+ || $c->cookie('ses')
+ || $c->req->param('ses')
+ // '';
+
+ $ses =~ s/\s+//;
+ $ses =~ s/^%22//; $ses =~ s/%22$//;
+ $ses =~ s/^['"]//; $ses =~ s/['"]$//;
+
+ if (!$ses) {
+ $fail_message = 'No authtoken provided';
+ } else {
+ $c->app->log->trace("Received auth token: $ses");
+ $c->stash(eg_auth_token => $ses);
+
+ my ($user_obj, $evt) = $U->checkses($ses);
+ if ($evt) {
+ log_authenticated_endpoint_access_attempt_event($c);
+ $fail_message = 'Invalid session';
+ } else {
+ $c->stash(eg_user_obj => $user_obj);
+ $c->stash(eg_user_id => $user_obj->id);
+
+ $c->app->log->trace("Stash updated for user: ".$c->stash('eg_user_id'));
+
+ my $pass = @$scopes ? 0 : 1; # No perms necessary? You can proceed.
+ for my $s (@$scopes) {
+ my $evt = $U->check_perms($user_obj->id, $user_obj->home_ou, split(',',$s));
+ $pass++ unless $evt;
+ }
+
+ if (!$pass) {
+ $fail_message = 'Permission denied';
+ } else {
+ my $throttle_user = check_request_limits(
+ $c->stash('operationId'),
+ $c->stash('eg_user_id'),
+ $c->forwarded_for
+ );
+
+ if ($throttle_user) {
+ $fail_message = 'Rate limit exceeded';
+ $c->res->code(429);
+ $c->res->headers->header('Retry-After' => $throttle_user);
+ } else {
+ $fail_message = undef; # we did it! no failure!
+ $c->stash(log_allowed => 1);
+ }
+ }
+ }
+ }
+
+ log_authenticated_endpoint_access_attempt_event($c);
+ return $cb->($c => $fail_message);
+}
+
+sub check_request_limits {
+ my $endpoint = shift;
+ my $user = shift;
+ my $ip = shift;
+
+ my $limits = new_editor()->json_query({from => ['openapi.check_generic_endpoint_rate_limit', $endpoint, $user, $ip]});
+ return $$limits[0]{'openapi.check_generic_endpoint_rate_limit'} if @$limits;
+
+ return undef; # proceed
+}
+
+sub generate_schemas {
+ my %schemas = (AnyValue => {nullable => Types::Serialiser::true});
+ for my $c (Fieldmapper->classes) {
+ my $h = $c->json_hint;
+ my $required = $c->Identity;
+
+ $schemas{$h} = {
+ type => 'object',
+ properties => {},
+ };
+
+ for my $p ($c->properties) {
+ my $info = $c->FieldInfo($p);
+
+ my $real_type = $$info{primitive} || $$info{datatype} || '';
+ my $type = $$info{datatype} || 'text';
+ my $format;
+ my $nullable = !$$info{required} ? Types::Serialiser::true : Types::Serialiser::false;
+
+ #XXX Fixing some broken data
+ $type = 'string' if ($real_type eq 'string' and $type eq 'float');
+ $nullable = Types::Serialiser::true if ($h eq 'au' and $p eq 'passwd');
+
+ if ($type eq 'timestamp') {
+ ($type,$format) = (string => 'date-time');
+ } elsif ($type eq 'id') {
+ ($type,$format) = (string => 'identifier');
+ } elsif ($type eq 'text') {
+ ($type,$format) = (string => undef);
+ } elsif ($type eq 'money') {
+ ($type,$format) = (string => 'money');
+ } elsif ($type eq 'bool') {
+ ($type,$format) = (boolean => undef);
+ } elsif ($type eq 'org_unit') {
+ ($type,$format) = (link => undef);
+ } elsif ($type eq 'int') {
+ ($type,$format) = (integer => 'int64');
+ } elsif ($type eq 'number' || $type eq 'float') {
+ ($type,$format) = (number => 'float');
+ } elsif ($type eq 'interval') {
+ ($type,$format) = (string => 'interval');
+ } elsif ($type ne 'link') {
+ ($type,$format) = (string => undef);
+ }
+
+ my $ref;
+ if ($type eq 'link' and my $link = $c->FieldLink($p)) {
+ $ref = { oneOf => [ { format => $$link{class}, type => 'string', nullable => $nullable }, { '$ref' => "#/components/schemas/$$link{class}" } ] };
+ $ref = $$link{reltype} eq 'has_many' ? { nullable => $nullable, type => array => items => $ref } : $ref;
+ } else {
+ $type = 'string' if $type eq 'link'; # fallback
+ $ref = { nullable => $nullable, type => $type };
+ #$$ref{format} = $format if ($format);
+ }
+
+ $schemas{$h}{properties}{$p} = $ref
+ }
+ }
+ return \%schemas;
+}
+
+sub apply_locale {
+ OpenSRF::AppSession->reset_locale;
+ OpenSRF::AppSession->default_locale(
+ parse_eg_locale(
+ parse_accept_lang($_[0]->req->headers->accept_language) || 'en_us'
+ )
+ );
+}
+
+sub parse_accept_lang {
+ my $al = shift;
+ return undef unless $al;
+ my ($locale) = split(/,/, $al);
+ ($locale) = split(/;/, $locale);
+ return undef unless $locale;
+ $locale =~ s/-/_/og;
+ return $locale;
+}
+
+# Accept-Language uses locales like 'en', 'fr', 'fr_fr', while Evergreen
+# internally uses 'en-US', 'fr-CA', 'fr-FR' (always with the 2 lowercase,
+# hyphen, 2 uppercase convention)
+sub parse_eg_locale {
+ my $ua_locale = shift || 'en_us';
+
+ $ua_locale =~ m/^(..).?(..)?$/;
+ my $lang_code = lc($1);
+ my $region_code = $2 ? uc($2) : uc($1);
+ return "$lang_code-$region_code";
+}
+
+sub to_bare_mixed_ref {
+ my $thing = shift;
+ my $c = shift;
+
+ my $filter_nulls = ref($c) ? 0 : $c;
+
+ if ($c and ref($c)) {
+ if (my $type_obj = $c->stash('eg_req_resolved_content_type_object')) {
+ $filter_nulls = $U->is_true($$type_obj{tokens}{filterNulls}) ? 1 : 0;
+ }
+
+ unless ($c->res->is_error) { # don't trim error output
+ my $op = $c->stash('operationId');
+ my $user_obj = $c->stash('eg_user_obj');
+
+ if ($op and $user_obj) {
+
+ # First, gather user settings applied to integrators
+ my $usettings = new_editor()->search_actor_user_setting({
+ usr => $user_obj->id,
+ name => ["REST.api.whitelist_properties.$op","REST.api.whitelist_properties",
+ "REST.api.blacklist_properties.$op","REST.api.blacklist_properties"]
+ });
+ my @whitelist_settings = map { OpenSRF::Utils::JSON->JSON2perl($_->value) } grep { $_->name =~ /^REST.api.whitelist_properties/ } @$usettings;
+ my @blacklist_settings = map { OpenSRF::Utils::JSON->JSON2perl($_->value) } grep { $_->name =~ /^REST.api.blacklist_properties/ } @$usettings;
+
+ # Then add YAOUSen to the list
+ my %YAOUSen = $U->ou_ancestor_setting_batch_insecure(
+ $user_obj->home_ou,
+ ["REST.api.whitelist_properties.$op","REST.api.whitelist_properties",
+ "REST.api.blacklist_properties.$op","REST.api.blacklist_properties"]
+ );
+ push @whitelist_settings, grep {defined and /\w+/} map { defined($YAOUSen{$_}) ? $YAOUSen{$_}{value} : undef } grep {/^REST.api.whitelist_properties/} keys %YAOUSen;
+ push @blacklist_settings, grep {defined and /\w+/} map { defined($YAOUSen{$_}) ? $YAOUSen{$_}{value} : undef } grep {/^REST.api.blacklist_properties/} keys %YAOUSen;
+
+ do {
+ $filter_nulls++; # force filtering of nulls because some properties can't be null but are allowed to not exist at all
+ $thing = filter_object_properties($c, $thing, join(',', @whitelist_settings), join(',', @blacklist_settings));
+ } if (@whitelist_settings or @blacklist_settings);
+ }
+ }
+ }
+
+ $thing = $thing->to_bare_hash if (blessed($thing) and $thing->isa('Fieldmapper'));
+
+ my $thing_type = ref($thing);
+ return $thing unless $thing_type;
+
+ if ($thing_type eq 'HASH') {
+ return filter_out_null_properties( { map { ($_, to_bare_mixed_ref($$thing{$_}, $c)) } keys %$thing }, $filter_nulls );
+ } elsif ($thing_type eq 'ARRAY') {
+ return [ map { to_bare_mixed_ref($_, $c) } @$thing ];
+ }
+
+ # dunno what to do with it...
+ return $thing;
+}
+
+sub filter_out_null_properties {
+ my $hashref = shift;
+ my $do_filter = shift;
+ return $hashref unless $do_filter;
+
+ my %replacement;
+ for my $key (keys %$hashref) {
+ $replacement{$key} = $$hashref{$key} if defined $$hashref{$key};
+ }
+
+ return \%replacement;
+}
+
+sub filter_object_properties {
+ my $c = shift;
+ my $obj = shift;
+ my $keep_proplist = shift || '';
+ my $toss_proplist = shift || '';
+ my $shallow = shift;
+
+ return $obj unless ($keep_proplist or $toss_proplist);
+
+ if (!ref($keep_proplist)) { # passed a comma separated list instead of an arrayref
+ $keep_proplist = [ grep {/\w+\.\w+/} map {s/^\s+//; s/\s+$//; $_} split ',', $keep_proplist ];
+ }
+
+ if (!ref($toss_proplist)) { # passed a comma separated list instead of an arrayref
+ $toss_proplist = [ grep {/\w+\.\w+/} map {s/^\s+//; s/\s+$//; $_} split ',', $toss_proplist ];
+ }
+
+ return $obj unless (@$keep_proplist or @$toss_proplist);
+
+ if (blessed($obj) and $obj->isa('Fieldmapper')) { # working an an FM object
+ my $hint = $obj->json_hint;
+ $c->app->log->trace("Checking for field restrictions on object type $hint");
+ my @my_keep_proplist = grep { /^$hint\./ } @$keep_proplist;
+ my @my_toss_proplist = grep { /^$hint\./ } @$toss_proplist;
+ if (@my_keep_proplist or @my_toss_proplist) { # we must have /at least/ one property to turn on filtering
+ for my $prop ($obj->properties) {
+ #$c->app->log->trace("Checking for field restrictions on object type $hint, property $prop");
+ if (defined $obj->$prop &&
+ (@my_keep_proplist and !grep { $_ eq "$hint.$prop" } @my_keep_proplist) || # we have a "keep" list but the property is not on it
+ (@my_toss_proplist and grep { $_ eq "$hint.$prop" } @my_toss_proplist) # or, we have a "toss" list and the property IS on it
+ ) {
+ $c->app->log->trace("Clearing property $prop on object type $hint");
+ my $clear_func = "clear_$prop";
+ $obj->$clear_func;
+ } elsif (!$shallow) {
+ $obj->$prop(filter_object_properties($c, $obj->$prop, $keep_proplist, $toss_proplist));
+ }
+ }
+ }
+ } elsif (ref($obj) and ref($obj) eq 'ARRAY') { # an arrayref of stuff
+ $obj = [ map { filter_object_properties($c, $_, $keep_proplist, $toss_proplist, $shallow) } @$obj ];
+ } elsif (ref($obj) and ref($obj) eq 'HASH') { # a hashref of stuff
+ $obj = { map { ($_ => filter_object_properties($c, $$obj{$_}, $keep_proplist, $toss_proplist, $shallow)) } keys %$obj };
+ }
+
+ return $obj;
+}
+
+# This package adds format validators for all IDL class hints
+package JSON::Validator::Schema::OpenAPIv3::Evergreen;
+use JSON::Validator::Schema::OpenAPIv3;
+use OpenSRF::Utils::Logger q/$logger/;
+use OpenILS::Utils::Fieldmapper;
+use base 'JSON::Validator::Schema::OpenAPIv3';
+
+our $_format_cache;
+sub _build_formats {
+ return $_format_cache if $_format_cache;
+ $_format_cache = JSON::Validator::Schema::OpenAPIv3::_build_formats();
+ $logger->info('Adding fieldmapper class hints to OpenAPI format validator mappings');
+ $$_format_cache{$_->json_hint} = sub { return undef } for (Fieldmapper->classes);
+ return $_format_cache;
+}
+
+1;
commit c8e65252507ff94dff5e1e19835c356f92321c2b
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Tue Dec 10 12:55:26 2024 -0500
LP#2067414: Add OpenAPI Perl dependencies
Adds Perl modules required to run the Mojolicious-based OpenAPI server
for Evergreen.
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Ruth Frasur Davis <rdavis(a)evergreencdi.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/Open-ILS/src/extras/install/Makefile.debian-bookworm b/Open-ILS/src/extras/install/Makefile.debian-bookworm
index cf41bfcd5a..a7ebae9809 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-bookworm
+++ b/Open-ILS/src/extras/install/Makefile.debian-bookworm
@@ -118,6 +118,11 @@ export CPAN_MODULES = \
Text::Levenshtein::Damerau::XS \
Pass::OTP \
Authen::WebAuthn \
+ IO::Prompter \
+ Mojolicious \
+ Mojolicious::Lite \
+ Mojolicious::Plugin::OpenAPI \
+ Mojolicious::Plugin::ForwardedFor \
Email::Send \
Duadua
diff --git a/Open-ILS/src/extras/install/Makefile.debian-bullseye b/Open-ILS/src/extras/install/Makefile.debian-bullseye
index fbf4935b61..57b8dfacaf 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-bullseye
+++ b/Open-ILS/src/extras/install/Makefile.debian-bullseye
@@ -118,6 +118,11 @@ export CPAN_MODULES = \
Email::Send \
Pass::OTP \
Authen::WebAuthn \
+ IO::Prompter \
+ Mojolicious \
+ Mojolicious::Lite \
+ Mojolicious::Plugin::OpenAPI \
+ Mojolicious::Plugin::ForwardedFor \
Locale::Country \
Duadua
diff --git a/Open-ILS/src/extras/install/Makefile.debian-buster b/Open-ILS/src/extras/install/Makefile.debian-buster
index c1ef3add99..1a268853f2 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-buster
+++ b/Open-ILS/src/extras/install/Makefile.debian-buster
@@ -118,6 +118,11 @@ export CPAN_MODULES = \
Text::Levenshtein::Damerau::XS \
Pass::OTP \
Authen::WebAuthn \
+ IO::Prompter \
+ Mojolicious \
+ Mojolicious::Lite \
+ Mojolicious::Plugin::OpenAPI \
+ Mojolicious::Plugin::ForwardedFor \
Email::Send \
Duadua
diff --git a/Open-ILS/src/extras/install/Makefile.fedora b/Open-ILS/src/extras/install/Makefile.fedora
index 64543ee2c7..2cff7f378a 100644
--- a/Open-ILS/src/extras/install/Makefile.fedora
+++ b/Open-ILS/src/extras/install/Makefile.fedora
@@ -92,6 +92,11 @@ export CPAN_MODULES = \
Config::General \
Pass::OTP \
Authen::WebAuthn \
+ IO::Prompter \
+ Mojolicious \
+ Mojolicious::Lite \
+ Mojolicious::Plugin::OpenAPI \
+ Mojolicious::Plugin::ForwardedFor \
Rose::URI \
Duadua
diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-jammy b/Open-ILS/src/extras/install/Makefile.ubuntu-jammy
index 90995526dd..635c12e4f1 100644
--- a/Open-ILS/src/extras/install/Makefile.ubuntu-jammy
+++ b/Open-ILS/src/extras/install/Makefile.ubuntu-jammy
@@ -117,6 +117,11 @@ export CPAN_MODULES = \
String::KeyboardDistance \
Pass::OTP \
Authen::WebAuthn \
+ IO::Prompter \
+ Mojolicious \
+ Mojolicious::Lite \
+ Mojolicious::Plugin::OpenAPI \
+ Mojolicious::Plugin::ForwardedFor \
Text::Levenshtein::Damerau::XS \
Duadua
diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-noble b/Open-ILS/src/extras/install/Makefile.ubuntu-noble
index 59f2c955ad..8aecc6baf1 100644
--- a/Open-ILS/src/extras/install/Makefile.ubuntu-noble
+++ b/Open-ILS/src/extras/install/Makefile.ubuntu-noble
@@ -118,6 +118,11 @@ export CPAN_MODULES = \
String::KeyboardDistance \
Pass::OTP \
Authen::WebAuthn \
+ IO::Prompter \
+ Mojolicious \
+ Mojolicious::Lite \
+ Mojolicious::Plugin::OpenAPI \
+ Mojolicious::Plugin::ForwardedFor \
Text::Levenshtein::Damerau::XS \
Duadua
commit 2a308edb8312a2b5ea422892de5fd7e8e0a2771a
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Tue Dec 10 12:52:03 2024 -0500
LP#2067414: API login support in open-ils.auth
This commit adds a new login type, api, with associated permission
checks and session timeout settings. Both the newer all-in-one calling
style and the older CHAP-style code paths are supported.
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Ruth Frasur Davis <rdavis(a)evergreencdi.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index 74b5532513..60a42fe73c 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -510,6 +510,7 @@ vim:et:ts=4:sw=4:
<!-- defined app-specific settings here -->
<default_timeout>
<!-- default login timeouts based on login type -->
+ <api>3600</api>
<opac>420</opac>
<staff>7200</staff>
<temp>300</temp>
diff --git a/Open-ILS/include/openils/oils_constants.h b/Open-ILS/include/openils/oils_constants.h
index b2ec580ecd..3cc1357984 100644
--- a/Open-ILS/include/openils/oils_constants.h
+++ b/Open-ILS/include/openils/oils_constants.h
@@ -6,6 +6,7 @@ extern "C" {
#endif
/* Settings ------------------------------------------------------ */
+#define OILS_ORG_SETTING_API_TIMEOUT "auth.api_timeout"
#define OILS_ORG_SETTING_OPAC_TIMEOUT "auth.opac_timeout"
#define OILS_ORG_SETTING_STAFF_TIMEOUT "auth.staff_timeout"
#define OILS_ORG_SETTING_TEMP_TIMEOUT "auth.temp_timeout"
diff --git a/Open-ILS/src/c-apps/oils_auth.c b/Open-ILS/src/c-apps/oils_auth.c
index 8234c5a770..38f9fbccf0 100644
--- a/Open-ILS/src/c-apps/oils_auth.c
+++ b/Open-ILS/src/c-apps/oils_auth.c
@@ -15,9 +15,13 @@
#define OILS_AUTH_OPAC "opac"
#define OILS_AUTH_STAFF "staff"
+#define OILS_AUTH_API "api"
#define OILS_AUTH_TEMP "temp"
#define OILS_AUTH_PERSIST "persist"
+#define OILS_PASSTYPE_MAIN "main"
+#define OILS_PASSTYPE_API "api"
+
// Default time for extending a persistent session: ten minutes
#define DEFAULT_RESET_INTERVAL 10 * 60
@@ -178,11 +182,12 @@ int osrfAppChildInit() {
}
// free() response
-static char* oilsAuthGetSalt(int user_id) {
+static char* oilsAuthGetSalt(int user_id, char* ptype) {
char* salt_str = NULL;
+ if (!ptype) ptype = OILS_PASSTYPE_MAIN;
jsonObject* params = jsonParseFmt( // free
- "{\"from\":[\"actor.get_salt\",%d,\"%s\"]}", user_id, "main");
+ "{\"from\":[\"actor.get_salt\",%d,\"%s\"]}", user_id, ptype);
jsonObject* salt_obj = // free
oilsUtilsCStoreReq("open-ils.cstore.json_query", params);
@@ -209,7 +214,7 @@ static char* oilsAuthGetSalt(int user_id) {
// ident is either a username or barcode
// Returns the init seed -> requires free();
static char* oilsAuthBuildInitCache(
- int user_id, const char* ident, const char* ident_type, const char* nonce) {
+ int user_id, const char* ident, const char* ident_type, const char* nonce, char* ptype) {
char* cache_key = va_list_to_string(
"%s%s%s", OILS_AUTH_CACHE_PRFX, ident, nonce);
@@ -222,7 +227,7 @@ static char* oilsAuthBuildInitCache(
// user does not exist. Use a dummy seed
auth_seed = strdup("x");
} else {
- auth_seed = oilsAuthGetSalt(user_id);
+ auth_seed = oilsAuthGetSalt(user_id, ptype);
}
jsonObject* seed_object = jsonParseFmt(
@@ -254,7 +259,7 @@ static char* oilsAuthBuildInitCache(
}
static int oilsAuthInitUsernameHandler(
- osrfMethodContext* ctx, const char* username, const char* nonce) {
+ osrfMethodContext* ctx, const char* username, const char* nonce, char* ptype) {
osrfLogInfo(OSRF_LOG_MARK,
"User logging in with username %s", username);
@@ -268,7 +273,7 @@ static int oilsAuthInitUsernameHandler(
jsonObjectFree(user_obj); // NULL OK
- char* seed = oilsAuthBuildInitCache(user_id, username, "username", nonce);
+ char* seed = oilsAuthBuildInitCache(user_id, username, "username", nonce, ptype);
resp = jsonNewObject(seed);
free(seed);
@@ -285,18 +290,23 @@ int oilsAuthInitUsername(osrfMethodContext* ctx) {
jsonObjectToSimpleString(jsonObjectGetIndex(ctx->params, 0));
const char* nonce =
jsonObjectGetString(jsonObjectGetIndex(ctx->params, 1));
+ const char* login_type =
+ jsonObjectGetString(jsonObjectGetIndex(ctx->params, 2));
+
+ char* ptype = OILS_PASSTYPE_MAIN;
+ if (login_type && !strcmp(login_type, OILS_AUTH_API)) ptype = OILS_PASSTYPE_API;
if (!nonce) nonce = "";
if (!username) return -1;
- int resp = oilsAuthInitUsernameHandler(ctx, username, nonce);
+ int resp = oilsAuthInitUsernameHandler(ctx, username, nonce, ptype);
free(username);
return resp;
}
static int oilsAuthInitBarcodeHandler(
- osrfMethodContext* ctx, const char* barcode, const char* nonce) {
+ osrfMethodContext* ctx, const char* barcode, const char* nonce, char* ptype) {
osrfLogInfo(OSRF_LOG_MARK,
"User logging in with barcode %s", barcode);
@@ -310,7 +320,7 @@ static int oilsAuthInitBarcodeHandler(
jsonObjectFree(user_obj); // NULL OK
- char* seed = oilsAuthBuildInitCache(user_id, barcode, "barcode", nonce);
+ char* seed = oilsAuthBuildInitCache(user_id, barcode, "barcode", nonce, ptype);
resp = jsonNewObject(seed);
free(seed);
@@ -328,11 +338,16 @@ int oilsAuthInitBarcode(osrfMethodContext* ctx) {
jsonObjectToSimpleString(jsonObjectGetIndex(ctx->params, 0));
const char* nonce =
jsonObjectGetString(jsonObjectGetIndex(ctx->params, 1));
+ const char* login_type =
+ jsonObjectGetString(jsonObjectGetIndex(ctx->params, 2));
+
+ char* ptype = OILS_PASSTYPE_MAIN;
+ if (login_type && !strcmp(login_type, OILS_AUTH_API)) ptype = OILS_PASSTYPE_API;
if (!nonce) nonce = "";
if (!barcode) return -1;
- int resp = oilsAuthInitBarcodeHandler(ctx, barcode, nonce);
+ int resp = oilsAuthInitBarcodeHandler(ctx, barcode, nonce, ptype);
free(barcode);
return resp;
@@ -403,6 +418,7 @@ static int oilsAuthIdentIsBarcode(const char* identifier, int org_id) {
is added to the auth init cache to differentiate between logins
using the same username and thus avoiding cache collisions for
near-simultaneous logins.
+ - login_type : optional, used to select password type
Return to client: Intermediate authentication seed.
*/
@@ -414,14 +430,19 @@ int oilsAuthInit(osrfMethodContext* ctx) {
jsonObjectToSimpleString(jsonObjectGetIndex(ctx->params, 0));
const char* nonce =
jsonObjectGetString(jsonObjectGetIndex(ctx->params, 1));
+ const char* login_type =
+ jsonObjectGetString(jsonObjectGetIndex(ctx->params, 2));
+
+ char* ptype = OILS_PASSTYPE_MAIN;
+ if (login_type && !strcmp(login_type, OILS_AUTH_API)) ptype = OILS_PASSTYPE_API;
if (!nonce) nonce = "";
if (!identifier) return -1; // we need an identifier
if (oilsAuthIdentIsBarcode(identifier, 0)) {
- resp = oilsAuthInitBarcodeHandler(ctx, identifier, nonce);
+ resp = oilsAuthInitBarcodeHandler(ctx, identifier, nonce, ptype);
} else {
- resp = oilsAuthInitUsernameHandler(ctx, identifier, nonce);
+ resp = oilsAuthInitUsernameHandler(ctx, identifier, nonce, ptype);
}
free(identifier);
@@ -454,7 +475,7 @@ int oilsAuthInit(osrfMethodContext* ctx) {
method or to receive the seed from the process that did so.
*/
static int oilsAuthVerifyPassword( const osrfMethodContext* ctx, int user_id,
- const char* identifier, const char* password, const char* nonce) {
+ const char* identifier, const char* password, const char* nonce, const char* login_type) {
int verified = 0;
@@ -464,11 +485,14 @@ static int oilsAuthVerifyPassword( const osrfMethodContext* ctx, int user_id,
free(key);
// Ask the DB to verify the user's password.
- // Here, the password is md5(md5(password) + salt)
+ // Here, the password is md5(salt + md5(password))
+
+ char* ptype = OILS_PASSTYPE_MAIN;
+ if (login_type && !strcmp(login_type, OILS_AUTH_API)) ptype = OILS_PASSTYPE_API;
jsonObject* params = jsonParseFmt( // free
- "{\"from\":[\"actor.verify_passwd\",%d,\"main\",\"%s\"]}",
- user_id, password);
+ "{\"from\":[\"actor.verify_passwd\",%d,\"%s\",\"%s\"]}",
+ user_id, ptype, password);
jsonObject* verify_obj = // free
oilsUtilsCStoreReq("open-ils.cstore.json_query", params);
@@ -512,10 +536,11 @@ static int oilsAuthVerifyPassword( const osrfMethodContext* ctx, int user_id,
* Turn the password into the nested md5 hash required of migrated
* passwords, then check the password in the DB.
*/
-static int oilsAuthLoginCheckPassword(int user_id, const char* password) {
+static int oilsAuthLoginCheckPassword(int user_id, const char* password, char* ptype) {
+ if (!ptype) ptype = OILS_PASSTYPE_MAIN;
growing_buffer* gb = osrf_buffer_init(33); // free me 1
- char* salt = oilsAuthGetSalt(user_id); // free me 2
+ char* salt = oilsAuthGetSalt(user_id, ptype); // free me 2
char* passhash = md5sum(password); // free me 3
osrf_buffer_add(gb, salt); // gb strdup's internally
@@ -533,7 +558,7 @@ static int oilsAuthLoginCheckPassword(int user_id, const char* password) {
jsonObject *arr = jsonNewObjectType(JSON_ARRAY);
jsonObjectPush(arr, jsonNewObject("actor.verify_passwd"));
jsonObjectPush(arr, jsonNewNumberObject((long) user_id));
- jsonObjectPush(arr, jsonNewObject("main"));
+ jsonObjectPush(arr, jsonNewObject(ptype));
jsonObjectPush(arr, jsonNewObject(finalpass));
jsonObject *params = jsonNewObjectType(JSON_HASH); // free me 6
jsonObjectSetKey(params, "from", arr);
@@ -559,7 +584,9 @@ static int oilsAuthLoginCheckPassword(int user_id, const char* password) {
}
static int oilsAuthLoginVerifyPassword(const osrfMethodContext* ctx,
- int user_id, const char* username, const char* password) {
+ int user_id, const char* username, const char* password, const char* login_type) {
+ char* ptype = OILS_PASSTYPE_MAIN;
+ if (login_type && !strcmp(login_type, OILS_AUTH_API)) ptype = OILS_PASSTYPE_API;
// build the cache key
growing_buffer* gb = osrf_buffer_init(64); // free me
@@ -587,7 +614,7 @@ static int oilsAuthLoginVerifyPassword(const osrfMethodContext* ctx,
}
}
- int verified = oilsAuthLoginCheckPassword(user_id, password);
+ int verified = oilsAuthLoginCheckPassword(user_id, password, ptype);
if (!verified) { // login failed. increment failure counter.
failcount++;
@@ -897,7 +924,7 @@ int oilsAuthComplete( osrfMethodContext* ctx ) {
// User exists and is not barred, etc. Test the password.
passOK = oilsAuthVerifyPassword(
- ctx, user_id, identifier, password, nonce);
+ ctx, user_id, identifier, password, nonce, type);
if (!passOK) {
// Password check failed. Return generic login failure.
@@ -1068,7 +1095,7 @@ int oilsAuthLogin(osrfMethodContext* ctx) {
}
if (!response && // user exists and is not barred, etc.
- !oilsAuthLoginVerifyPassword(ctx, user_id, username, password)) {
+ !oilsAuthLoginVerifyPassword(ctx, user_id, username, password, type)) {
// User provided the wrong password or is blocked from too
// many previous login failures.
diff --git a/Open-ILS/src/c-apps/oils_auth_internal.c b/Open-ILS/src/c-apps/oils_auth_internal.c
index 15dd24aebd..373c8ffba2 100644
--- a/Open-ILS/src/c-apps/oils_auth_internal.c
+++ b/Open-ILS/src/c-apps/oils_auth_internal.c
@@ -21,6 +21,7 @@
#define OILS_AUTH_STAFF "staff"
#define OILS_AUTH_TEMP "temp"
#define OILS_AUTH_PERSIST "persist"
+#define OILS_AUTH_API "api"
#define BLOCK_EXPIRED_STAFF_LOGIN_FLAG "auth.block_expired_staff_login"
@@ -37,6 +38,7 @@ static long _oilsAuthOPACTimeout = 0;
static long _oilsAuthStaffTimeout = 0;
static long _oilsAuthOverrideTimeout = 0;
static long _oilsAuthPersistTimeout = 0;
+static long _oilsAuthAPITimeout = 0;
/**
@brief Initialize the application by registering functions for method calls.
@@ -145,6 +147,15 @@ static long oilsAuthGetTimeout(
_oilsAuthOPACTimeout = 0;
}
+ value_obj = osrf_settings_host_value_object(
+ "/apps/open-ils.auth_internal/app_settings/default_timeout/api" );
+ _oilsAuthAPITimeout = oilsUtilsIntervalToSeconds( jsonObjectGetString( value_obj ));
+ jsonObjectFree(value_obj);
+ if( -1 == _oilsAuthAPITimeout ) {
+ osrfLogWarning( OSRF_LOG_MARK, "Invalid default timeout for API logins" );
+ _oilsAuthAPITimeout = 0;
+ }
+
value_obj = osrf_settings_host_value_object(
"/apps/open-ils.auth_internal/app_settings/default_timeout/staff" );
_oilsAuthStaffTimeout = oilsUtilsIntervalToSeconds( jsonObjectGetString( value_obj ));
@@ -173,9 +184,9 @@ static long oilsAuthGetTimeout(
}
osrfLogInfo(OSRF_LOG_MARK, "Set default auth timeouts: "
- "opac => %ld : staff => %ld : temp => %ld : persist => %ld",
+ "opac => %ld : staff => %ld : temp => %ld : persist => %ld : api => %ld",
_oilsAuthOPACTimeout, _oilsAuthStaffTimeout,
- _oilsAuthOverrideTimeout, _oilsAuthPersistTimeout );
+ _oilsAuthOverrideTimeout, _oilsAuthPersistTimeout, _oilsAuthAPITimeout );
}
int home_ou = (int) jsonObjectGetNumber( oilsFMGetObject( userObj, "home_ou" ));
@@ -188,6 +199,9 @@ static long oilsAuthGetTimeout(
if( !strcmp( type, OILS_AUTH_OPAC )) {
setting = OILS_ORG_SETTING_OPAC_TIMEOUT;
default_timeout = _oilsAuthOPACTimeout;
+ } else if( !strcmp( type, OILS_AUTH_API )) {
+ setting = OILS_ORG_SETTING_API_TIMEOUT;
+ default_timeout = _oilsAuthAPITimeout;
} else if( !strcmp( type, OILS_AUTH_STAFF )) {
setting = OILS_ORG_SETTING_STAFF_TIMEOUT;
default_timeout = _oilsAuthStaffTimeout;
@@ -275,6 +289,9 @@ static oilsEvent* oilsAuthCheckLoginPerm(osrfMethodContext* ctx,
if (!strcasecmp(type, OILS_AUTH_OPAC)) {
perms[0] = "OPAC_LOGIN";
+ } else if (!strcasecmp(type, OILS_AUTH_API)) {
+ perms[0] = "API_LOGIN";
+
} else if (!strcasecmp(type, OILS_AUTH_STAFF)) {
perms[0] = "STAFF_LOGIN";
commit 5c79bf7d69d99f0e72882d46b112d51d3162d539
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Tue Dec 10 12:55:03 2024 -0500
LP#2067414: Fieldmapper utility class enhancements
This commit adds enhancements to the Fieldmapper utility code for
deserializing and reserializating nested IDL-based objects.
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Ruth Frasur Davis <rdavis(a)evergreencdi.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
index 7ff10d293f..2dea5a0edf 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm
@@ -6,6 +6,7 @@ use OpenSRF::Utils::SettingsClient;
use OpenSRF::System;
use XML::LibXML;
use Scalar::Util 'blessed';
+use Types::Serialiser;
my $log = 'OpenSRF::Utils::Logger';
@@ -423,6 +424,13 @@ sub FieldDatatype {
return $$fieldmap{$class_name}{fields}{$field}{datatype};
}
+sub FieldLink {
+ my $self = shift;
+ my $f = shift;
+ return undef unless ($f && exists $$fieldmap{$self->class_name}{links}{$f});
+ return $$fieldmap{$self->class_name}{links}{$f};
+}
+
sub class_name {
my $class_name = shift;
return ref($class_name) || $class_name;
@@ -457,10 +465,38 @@ sub properties {
sub to_bare_hash {
my $self = shift;
+ my $deep = shift;
+ my $cname = $self->class_name;
my %hash = ();
for my $f ($self->properties) {
my $val = $self->$f;
+ my $vtype = $cname->FieldDatatype($f) || '';
+ if ($deep
+ and ref($val)
+ and exists $$fieldmap{$cname}{links}{$f}
+ ) {
+ my $fclass = Fieldmapper::class_for_hint($$fieldmap{$cname}{links}{$f}{class});
+ if ($fclass and $$fieldmap{$cname}{links}{$f}{reltype} eq 'has_many' and @$val) {
+ $val = [ map { (blessed($_) and $_->isa('Fieldmapper')) ? $_->to_bare_hash($deep) : $_ } @$val ];
+ } elsif (blessed($val) and $val->isa('Fieldmapper')) {
+ $val = $val->to_bare_hash($deep);
+ }
+ } elsif (defined($val) and $vtype eq 'bool') {
+ $val = ($val and $val !~ /^f$/i) ? Types::Serialiser::true : Types::Serialiser::false;
+ } elsif (
+ $val
+ and $vtype eq 'timestamp'
+ and $val =~ /^(\S{10}T\S{8}[-+]\d{2})(\d{2})$/
+ ) {
+ $val = "$1:$2";
+ } elsif (
+ $val
+ and $vtype eq 'timestamp'
+ and $val =~ /^\S{10}$/
+ ) {
+ $val .= 'T00:00:00';
+ }
$hash{$f} = $val;
}
@@ -473,9 +509,24 @@ sub to_bare_hash {
sub from_bare_hash {
my $self = shift;
my $hash = shift;
+ my $deep = shift;
+ my $cname = $self->class_name;
+
my @value = ();
for my $f ($self->properties) {
- push @value, $$hash{$f};
+ my $val = $$hash{$f};
+ if ($deep
+ and ref($val)
+ and $self->FieldDatatype($f) eq 'link'
+ ) {
+ my $fclass = Fieldmapper::class_for_hint($$fieldmap{$cname}{links}{$f}{class});
+ if ($fclass and $$fieldmap{$cname}{links}{$f}{reltype} eq 'has_many' and @$val) {
+ $val = [ map { $fclass->from_bare_hash($_, $deep) } @$val ];
+ } else {
+ $val = $fclass->from_bare_hash($val, $deep);
+ }
+ }
+ push @value, $val;
}
return $self->new(\@value);
}
commit 461e5cda890ebaab4d013f8e0b3a14e50e09b6e7
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Fri Dec 20 16:17:39 2024 -0500
LP#2067414: Existing API enhancements
In order to efficiently and flexibly generate the data likely needed by
RESTful OpenAPI clients, a couple small enhancements to existing OpenSRF
methods are provided here:
* Enhance display field output retrieval
* Add sorting option to MBTS retrieval
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Ruth Frasur Davis <rdavis(a)evergreencdi.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
Signed-off-by: Jeff Godin <jgodin(a)tadl.org>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
index 6d724695dc..ec2722990a 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
@@ -2574,7 +2574,7 @@ sub user_transaction_history {
$filter->{'total_owed'} = { '<>' => 0 };
}
- my $options_clause = { order_by => { mbt => 'xact_start DESC' } };
+ my $options_clause = { order_by => { mbt => 'xact_start '.($$options{sort} ? uc($$options{sort}) : 'DESC') } };
$options_clause->{'limit'} = $options->{'limit'} if $options->{'limit'};
$options_clause->{'offset'} = $options->{'offset'} if $options->{'offset'};
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
index 9e96b0ad6b..16f42e48f5 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
@@ -1648,14 +1648,21 @@ sub fetch_display_fields {
}
my $e = new_editor();
+ my $fleshed = 0;
+ my %df_cache;
+
+ if ($self->api_name =~ /fleshed$/) {
+ $fleshed++;
+ %df_cache = map {
+ ($_->id => {%{$_->to_bare_hash}{qw/id field_class name label search_field browse_field facet_field display_field restrict/}})
+ } @{ $e->retrieve_all_config_metabib_field };
+ }
for my $record ( @records ) {
next unless ($record && $highlight_map);
- $conn->respond(
- $e->json_query(
- {from => ['search.highlight_display_fields', $record, $highlight_map]}
- )
- );
+ my $hl = $e->json_query({from => ['search.highlight_display_fields', $record, $highlight_map]});
+ $hl = [ map { $$_{field} = $df_cache{$$_{field}}; $_ } @$hl ] if $fleshed;
+ $conn->respond( $hl );
}
return undef;
@@ -1666,6 +1673,12 @@ __PACKAGE__->register_method(
stream => 1
);
+__PACKAGE__->register_method(
+ method => 'fetch_display_fields',
+ api_name => 'open-ils.search.fetch.metabib.display_field.highlight.fleshed',
+ stream => 1
+);
+
sub tag_circulated_records {
my ($auth, $results, $metabib) = @_;
-----------------------------------------------------------------------
Summary of changes:
Open-ILS/examples/fm_IDL.xml | 468 ++++-
Open-ILS/examples/opensrf.xml.example | 1 +
Open-ILS/include/openils/oils_constants.h | 1 +
Open-ILS/src/c-apps/oils_auth.c | 73 +-
Open-ILS/src/c-apps/oils_auth_internal.c | 21 +-
.../src/extras/install/Makefile.debian-bookworm | 5 +
.../src/extras/install/Makefile.debian-bullseye | 5 +
Open-ILS/src/extras/install/Makefile.debian-buster | 5 +
Open-ILS/src/extras/install/Makefile.fedora | 5 +
Open-ILS/src/extras/install/Makefile.ubuntu-jammy | 5 +
Open-ILS/src/extras/install/Makefile.ubuntu-noble | 5 +
.../src/perlmods/lib/OpenILS/Application/Actor.pm | 2 +-
.../lib/OpenILS/Application/Search/Biblio.pm | 23 +-
.../src/perlmods/lib/OpenILS/OpenAPI/Controller.pm | 149 ++
.../perlmods/lib/OpenILS/OpenAPI/Controller/bib.pm | 236 +++
.../lib/OpenILS/OpenAPI/Controller/course.pm | 60 +
.../lib/OpenILS/OpenAPI/Controller/hold.pm | 117 ++
.../perlmods/lib/OpenILS/OpenAPI/Controller/org.pm | 44 +
.../lib/OpenILS/OpenAPI/Controller/patron.pm | 316 +++
.../src/perlmods/lib/OpenILS/Utils/Fieldmapper.pm | 53 +-
Open-ILS/src/sql/Pg/002.schema.config.sql | 2 +-
Open-ILS/src/sql/Pg/005.schema.actors.sql | 39 -
Open-ILS/src/sql/Pg/610.schema.openapi.sql | 446 +++++
Open-ILS/src/sql/Pg/950.data.seed-values.sql | 955 ++++++++-
Open-ILS/src/sql/Pg/999.functions.global.sql | 52 +
Open-ILS/src/sql/Pg/sql_file_manifest | 1 +
.../src/sql/Pg/upgrade/1468.schema.openapi.sql | 1472 ++++++++++++++
Open-ILS/src/support-scripts/api_ctl | 2043 ++++++++++++++++++++
Open-ILS/src/support-scripts/openapi_server | 1016 ++++++++++
.../assets/images/restful_api/api_explorer.png | Bin 0 -> 41415 bytes
.../assets/images/restful_api/block_expanded.png | Bin 0 -> 47107 bytes
.../assets/images/restful_api/create_endpoint.png | Bin 0 -> 88878 bytes
.../images/restful_api/expected_parameters.png | Bin 0 -> 31761 bytes
.../assets/images/restful_api/method_details.png | Bin 0 -> 107160 bytes
docs/modules/integrations/nav.adoc | 1 +
docs/modules/integrations/pages/restful_api.adoc | 883 +++++++++
36 files changed, 8427 insertions(+), 77 deletions(-)
create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller.pm
create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/bib.pm
create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/course.pm
create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/hold.pm
create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/org.pm
create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/OpenAPI/Controller/patron.pm
create mode 100755 Open-ILS/src/sql/Pg/610.schema.openapi.sql
create mode 100755 Open-ILS/src/sql/Pg/upgrade/1468.schema.openapi.sql
create mode 100755 Open-ILS/src/support-scripts/api_ctl
create mode 100755 Open-ILS/src/support-scripts/openapi_server
create mode 100644 docs/modules/integrations/assets/images/restful_api/api_explorer.png
create mode 100644 docs/modules/integrations/assets/images/restful_api/block_expanded.png
create mode 100644 docs/modules/integrations/assets/images/restful_api/create_endpoint.png
create mode 100644 docs/modules/integrations/assets/images/restful_api/expected_parameters.png
create mode 100644 docs/modules/integrations/assets/images/restful_api/method_details.png
create mode 100644 docs/modules/integrations/pages/restful_api.adoc
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch main updated. b10b089b32f7f920764177942965bd10af9b9c27
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via b10b089b32f7f920764177942965bd10af9b9c27 (commit)
via 1715ac44996fbb7e710d91b5827896f20dbb53d9 (commit)
via fa784dcbf647689ebffa11157afa4a7734dc08ce (commit)
via 51b86f2ec966c7cdad2d575a19c7ae4e771ea82c (commit)
via 72a8cc4bb0a25b1521e79e241a8ffe7401c164bd (commit)
via ebc9613ff23a162d585ba21fc50d9bde2898595b (commit)
from 2cd643df8a207eff8f232a97efa37471b9e65da8 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit b10b089b32f7f920764177942965bd10af9b9c27
Author: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Date: Fri Mar 21 20:31:44 2025 +0000
Stamping upgrade scripts for course link tracker
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 27f620b41c..5a26813b54 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1466', :eg_version); -- sleary/miker
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1467', :eg_version); -- sandbergja/blake/sleary
CREATE TABLE config.bib_source (
id SERIAL PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql b/Open-ILS/src/sql/Pg/upgrade/1467.schema.link_click.sql
similarity index 95%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql
rename to Open-ILS/src/sql/Pg/upgrade/1467.schema.link_click.sql
index 6fd5f903ad..bfb609dd18 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1467.schema.link_click.sql
@@ -1,6 +1,6 @@
BEGIN;
--- SELECT evergreen.upgrade_deps_block_check('xxxx', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1467', :eg_version);
CREATE TABLE action.eresource_link_click (
id BIGSERIAL PRIMARY KEY,
commit 1715ac44996fbb7e710d91b5827896f20dbb53d9
Author: Jane Sandberg <sandbergja(a)gmail.com>
Date: Sat Jul 20 07:17:07 2024 -0700
LP1895695: Incorporate feedback from review
* Move module out of OpenILS::Application into OpenILS::WWW, since it
does not implement an OpenSRF application.
* Add POD descriptions of both perl modules in this feature.
* Remove the IDL required flag for a field with a DEFAULT.
* Make action.eresource_link_click_course.course NULLable.
Signed-off-by: Jane Sandberg <sandbergja(a)gmail.com>
Signed-off-by: blake <blake(a)mobiusconsortium.org>
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 56f0d16c2b..1a46e9a133 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -15902,7 +15902,7 @@ SELECT usr,
reporter:label="Eresource link clicks">
<fields oils_persist:primary="id" oils_persist:sequence="action.eresource_link_click_id_seq">
<field reporter:label="ID" name="id" reporter:datatype="id" />
- <field reporter:label="Date/time of click" name="clicked_at" reporter:datatype="timestamp" oils_obj:required="true"/>
+ <field reporter:label="Date/time of click" name="clicked_at" reporter:datatype="timestamp"/>
<field reporter:label="URL" name="url" reporter:datatype="text" oils_obj:required="true"/>
<field reporter:label="Record" name="record" reporter:datatype="link"/>
<field reporter:label="Courses" name="courses" oils_persist:virtual="true" reporter:datatype="link"/>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/README.adoc b/Open-ILS/src/perlmods/lib/OpenILS/Application/README.adoc
new file mode 100644
index 0000000000..a0d82e8122
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/README.adoc
@@ -0,0 +1,7 @@
+This OpenILS::Application Perl module namespace is generally
+reserved for modules (and module groups) that provide an OpenSRF
+application implementation.
+
+If you are writing a Perl module that does not implement
+an OpenSRF application, consider adding it to a different directory
+and namespace.
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm
index 808886c118..61adb945ca 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm
@@ -1,9 +1,19 @@
+=head1 NAME
+
+OpenILS::WWW::EResourceLinkClick
+
+=head1 DESCRIPTION
+
+This module is responsible for accepting data about
+eresource link clicks from HTTP requests.
+=cut
+
package OpenILS::WWW::EResourceLinkClick;
use strict;
use warnings;
-use OpenILS::Application::EResourceLinkClick;
+use OpenILS::WWW::EResourceLinkClick::Click;
use Apache2::Const -compile => qw(
OK HTTP_BAD_REQUEST HTTP_INTERNAL_SERVER_ERROR HTTP_NOT_IMPLEMENTED
);
@@ -18,23 +28,23 @@ sub handler {
my $referer = $cgi->http('Referer') || '';
my $user_agent = $cgi->http('User-Agent') || '';
- my $result = OpenILS::Application::EResourceLinkClick->add_click(
+ my $result = OpenILS::WWW::EResourceLinkClick::Click->add_click(
$record_id,
$url,
$referer,
$user_agent
);
- if( $result eq OpenILS::Application::EResourceLinkClick::BadInput ) {
+ if( $result eq OpenILS::WWW::EResourceLinkClick::Click::BadInput ) {
return Apache2::Const::HTTP_BAD_REQUEST;
}
- if( $result eq OpenILS::Application::EResourceLinkClick::InternalError ) {
+ if( $result eq OpenILS::WWW::EResourceLinkClick::Click::InternalError ) {
return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
}
- if( $result eq OpenILS::Application::EResourceLinkClick::NotConfigured ) {
+ if( $result eq OpenILS::WWW::EResourceLinkClick::Click::NotConfigured ) {
return Apache2::Const::HTTP_NOT_IMPLEMENTED;
}
- if( $result eq OpenILS::Application::EResourceLinkClick::Success ) {
+ if( $result eq OpenILS::WWW::EResourceLinkClick::Click::Success ) {
$r->content_type('text/plain');
$r->print('click recorded');
return Apache2::Const::OK;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EResourceLinkClick.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick/Click.pm
similarity index 92%
rename from Open-ILS/src/perlmods/lib/OpenILS/Application/EResourceLinkClick.pm
rename to Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick/Click.pm
index 308de5a63d..0cfe221640 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/EResourceLinkClick.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick/Click.pm
@@ -1,4 +1,14 @@
-package OpenILS::Application::EResourceLinkClick;
+=head1 NAME
+
+OpenILS::WWW::EResourceLinkClick::Click.pm
+
+=head1 DESCRIPTION
+This module is responsible for validating and
+persisting information about a click on
+an eresource link.
+=cut
+
+package OpenILS::WWW::EResourceLinkClick::Click;
use OpenILS::Application;
use base qw/OpenILS::Application/;
use OpenILS::Utils::CStoreEditor qw/:funcs/;
diff --git a/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t b/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t
index f3a26c1282..87768e47c2 100644
--- a/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t
+++ b/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t
@@ -6,7 +6,7 @@ use Test::More tests => 2;
use OpenILS::Utils::TestUtils;
use OpenILS::Utils::CStoreEditor qw/:funcs/;
-diag('Test the EResourceLinkClick module');
+diag('Test the EResourceLinkClick::Click module');
my $script = OpenILS::Utils::TestUtils->new();
$script->bootstrap;
@@ -14,7 +14,7 @@ our $apputils = "OpenILS::Application::AppUtils";
my $e = new_editor;
$e->init;
-BEGIN { use_ok('OpenILS::Application::EResourceLinkClick'); }
+BEGIN { use_ok('OpenILS::WWW::EResourceLinkClick::Click'); }
subtest('add_click', sub {
plan tests => 2;
@@ -23,14 +23,14 @@ subtest('add_click', sub {
plan tests => 2;
set_global_flag($e, 'f');
- my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ my $response = OpenILS::WWW::EResourceLinkClick::Click->add_click(
238,
'http://example.com/ebookapi/t/001',
'https://my-evergreen.org/eg/opac/results',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
);
- is($response, OpenILS::Application::EResourceLinkClick::NotConfigured, 'says that it is not configured');
+ is($response, OpenILS::WWW::EResourceLinkClick::Click::NotConfigured, 'says that it is not configured');
assert_no_clicks_added_to_db($e);
});
@@ -41,53 +41,53 @@ subtest('add_click', sub {
subtest('when the referer did not come from the record or results page', sub {
plan tests => 2;
- my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ my $response = OpenILS::WWW::EResourceLinkClick::Click->add_click(
238,
'http://example.com/ebookapi/t/001',
'https://some-non-eg-site/bad-path',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
);
- is($response, OpenILS::Application::EResourceLinkClick::BadInput, 'says that the input is bad');
+ is($response, OpenILS::WWW::EResourceLinkClick::Click::BadInput, 'says that the input is bad');
assert_no_clicks_added_to_db($e);
});
subtest('when user agent is a bot', sub {
plan tests => 2;
- my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ my $response = OpenILS::WWW::EResourceLinkClick::Click->add_click(
238,
'http://example.com/ebookapi/t/001',
'https://my-evergreen.org/eg/opac/results',
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
);
- is($response, OpenILS::Application::EResourceLinkClick::BadInput, 'says that the input is bad');
+ is($response, OpenILS::WWW::EResourceLinkClick::Click::BadInput, 'says that the input is bad');
assert_no_clicks_added_to_db($e);
});
subtest('when url does not exist on the record in question', sub {
plan tests => 2;
- my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ my $response = OpenILS::WWW::EResourceLinkClick::Click->add_click(
238,
'http://not-a-real-url/not-actually/on-the-record',
'https://my-evergreen.org/eg/opac/results',
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
);
- is($response, OpenILS::Application::EResourceLinkClick::BadInput, 'says that the input is bad');
+ is($response, OpenILS::WWW::EResourceLinkClick::Click::BadInput, 'says that the input is bad');
assert_no_clicks_added_to_db($e);
});
subtest('when input is valid', sub {
plan tests => 2;
- my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ my $response = OpenILS::WWW::EResourceLinkClick::Click->add_click(
238,
'http://example.com/ebookapi/t/001',
'https://my-evergreen.org/eg/opac/results',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
);
- is($response, OpenILS::Application::EResourceLinkClick::Success, 'says that it is successful');
+ is($response, OpenILS::WWW::EResourceLinkClick::Click::Success, 'says that it is successful');
my $rows = $e->search_action_eresource_link_click({record => 238});
is(scalar(@{ $rows }), 1, 'adds the click to the database');
@@ -112,14 +112,14 @@ subtest('add_click', sub {
$e->create_asset_course_module_course_materials( $acmcm );
$e->xact_commit;
- my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ my $response = OpenILS::WWW::EResourceLinkClick::Click->add_click(
238,
'http://example.com/ebookapi/t/001',
'https://my-evergreen.org/eg/opac/results',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
);
- is($response, OpenILS::Application::EResourceLinkClick::Success, 'says that it is successful');
+ is($response, OpenILS::WWW::EResourceLinkClick::Click::Success, 'says that it is successful');
my $rows = $e->search_action_eresource_link_click_course({course => 12345});
is(scalar(@{ $rows }), 1, 'adds a click course mapping to the database');
is($rows->[0]->course_name, 'Introduction to cats', 'adds the course name to the mapping');
diff --git a/Open-ILS/src/sql/Pg/090.schema.action.sql b/Open-ILS/src/sql/Pg/090.schema.action.sql
index 9c35cb73f9..714265bf20 100644
--- a/Open-ILS/src/sql/Pg/090.schema.action.sql
+++ b/Open-ILS/src/sql/Pg/090.schema.action.sql
@@ -1835,7 +1835,7 @@ CREATE TABLE action.eresource_link_click (
CREATE TABLE action.eresource_link_click_course (
id SERIAL PRIMARY KEY,
click BIGINT NOT NULL REFERENCES action.eresource_link_click (id) ON DELETE CASCADE,
- course INT NOT NULL, -- no REFERENCES, since the course could have been deleted
+ course INT REFERENCES asset.course_module_course (id) ON UPDATE CASCADE ON DELETE SET NULL,
course_name TEXT NOT NULL,
course_number TEXT NOT NULL
);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql
index 5067383601..6fd5f903ad 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.link_click.sql
@@ -12,7 +12,7 @@ CREATE TABLE action.eresource_link_click (
CREATE TABLE action.eresource_link_click_course (
id SERIAL PRIMARY KEY,
click BIGINT NOT NULL REFERENCES action.eresource_link_click (id) ON DELETE CASCADE,
- course INT NOT NULL, -- no REFERENCES, since the course could have been deleted
+ course INT REFERENCES asset.course_module_course (id) ON UPDATE CASCADE ON DELETE SET NULL,
course_name TEXT NOT NULL,
course_number TEXT NOT NULL
);
commit fa784dcbf647689ebffa11157afa4a7734dc08ce
Author: Jane Sandberg <sandbergja(a)gmail.com>
Date: Sat Mar 9 05:43:21 2024 -0800
LP1895695: Add docs for the eresource link click feature
Signed-off-by: Jane Sandberg <sandbergja(a)gmail.com>
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
diff --git a/docs/RELEASE_NOTES_NEXT/OPAC/eresource_link_click_track.adoc b/docs/RELEASE_NOTES_NEXT/OPAC/eresource_link_click_track.adoc
new file mode 100644
index 0000000000..35be9e5aff
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/OPAC/eresource_link_click_track.adoc
@@ -0,0 +1,67 @@
+== Eresource link click tracking ==
+
+This version of Evergreen introduces the ability
+to track user clicks on eresources in the public
+catalog.
+
+This feature provides usage data on eresources in
+the catalog. One potential use for this feature
+is to provide the equivalent of circulation
+statistics for online course materials.
+
+Data can be accessed via the Evergreen reporter.
+
+=== Data collection
+
+This feature does not collect any personally
+identifiable data about the user who clicks
+on the link. The pieces of data that are
+collected are:
+
+* The URL clicked
+* The time it was clicked
+* The bibliographic record that contains the
+URL.
+* The ID, name, and number of any courses
+that use the bibliographic record.
+
+=== Enabling the feature
+
+The new tables can grow forever, so before
+enabling this feature:
+
+* Ensure that you are monitoring disk space on
+the server(s) that house your postgres database.
+* Decide on a retention period for click data,
+and set up the provided
+``delete_old_eresource_link_clicks``
+script to delete old data regularly.
+
+To enable this feature:
+
+. Set the
+``opac.eresources.link_click_tracking``
+global flag to true.
+. Restart memcached and apache HTTP server.
+
+
+=== Accuracy
+
+Statistics from this feature are collected
+on a best efforts basis, and have certain
+limitations when it comes to accuracy:
+
+* It does
+not provide any guarantees against somebody
+deliberately inflating the statistics of a
+particular link, either through repeated network
+calls or repeatedly clicking on a link they don't
+actually intend to read.
+* Clicks from certain very old, unsupported browsers
+(notable Microsoft Internet Explorer) will
+not be counted.
+* Major bots are excluded, but uncommon bots and
+bots that set a misleading User Agent header are
+included unless they are blocked at the web server
+or load balancer level.
+
diff --git a/docs/modules/opac/nav.adoc b/docs/modules/opac/nav.adoc
index 848a0d704d..51cf46a8fb 100644
--- a/docs/modules/opac/nav.adoc
+++ b/docs/modules/opac/nav.adoc
@@ -8,5 +8,6 @@
** xref:opac:advanced_features.adoc[Bibliographic Search Enhancements]
** xref:opac:tpac_meta_record_holds.adoc[TPAC Metarecord Search and Metarecord Level Holds]
** xref:opac:linked_libraries.adoc[Library Information Pages]
+** xref:opac:eresource_link_click_track.adoc[Eresource Link Click Tracking]
** xref:opac:opensearch.adoc[Adding Evergreen Search to Web Browsers]
** xref:opac:search_form.adoc[Adding an Evergreen search form to a web page]
diff --git a/docs/modules/opac/pages/eresource_link_click_track.adoc b/docs/modules/opac/pages/eresource_link_click_track.adoc
new file mode 100644
index 0000000000..d017c59fa1
--- /dev/null
+++ b/docs/modules/opac/pages/eresource_link_click_track.adoc
@@ -0,0 +1,67 @@
+= Eresource link click tracking =
+:toc:
+
+Evergreen has a feature
+to track user clicks on eresources in the public
+catalog.
+
+This feature provides usage data on eresources in
+the catalog. One potential use for this feature
+is to provide the equivalent of circulation
+statistics for online course materials.
+
+Data can be accessed via the Evergreen reporter.
+
+== Data collection ==
+
+This feature does not collect any personally
+identifiable data about the user who clicks
+on the link. The pieces of data that are
+collected are:
+
+* The URL clicked
+* The time it was clicked
+* The bibliographic record that contains the
+URL.
+* The ID, name, and number of any courses
+that use the bibliographic record.
+
+== Enabling the feature ==
+
+The new tables can grow forever, so before
+enabling this feature:
+
+* Ensure that you are monitoring disk space on
+the server(s) that house your postgres database.
+* Decide on a retention period for click data,
+and set up the provided
+``delete_old_eresource_link_clicks``
+script to delete old data regularly.
+
+To enable this feature:
+
+. Set the
+``opac.eresources.link_click_tracking``
+global flag to true.
+. Restart memcached and apache HTTP server.
+
+
+== Accuracy ==
+
+Statistics from this feature are collected
+on a best efforts basis, and have certain
+limitations when it comes to accuracy:
+
+* It does
+not provide any guarantees against somebody
+deliberately inflating the statistics of a
+particular link, either through repeated network
+calls or repeatedly clicking on a link they don't
+actually intend to read.
+* Clicks from certain very old, unsupported browsers
+(notable Microsoft Internet Explorer) will
+not be counted.
+* Major bots are excluded, but uncommon bots and
+bots that set a misleading User Agent header are
+included unless they are blocked at the web server
+or load balancer level.
commit 51b86f2ec966c7cdad2d575a19c7ae4e771ea82c
Author: Jane Sandberg <sandbergja(a)gmail.com>
Date: Fri Mar 8 21:45:06 2024 -0800
LP1895695: Add OPAC parts for the eresource click track feature
Signed-off-by: Jane Sandberg <sandbergja(a)gmail.com>
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/header.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/header.tt2
index 52c6c777be..98383b7763 100755
--- a/Open-ILS/src/templates-bootstrap/opac/parts/header.tt2
+++ b/Open-ILS/src/templates-bootstrap/opac/parts/header.tt2
@@ -137,6 +137,8 @@
want_dojo = 1;
END;
+ eresource_click_track = ctx.get_cgf("opac.eresources.link_click_tracking");
+
# ... and for interfaces that require manual trigger of action triggers
IF can_call_action_trigger == 'true';
want_dojo = 1;
diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/js.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/js.tt2
index 6745dec584..71fe9b4cae 100755
--- a/Open-ILS/src/templates-bootstrap/opac/parts/js.tt2
+++ b/Open-ILS/src/templates-bootstrap/opac/parts/js.tt2
@@ -191,6 +191,13 @@ var aou_hash = {
<script >if ($('#client_tz_id')) { $('#client_tz_id').value = OpenSRF.tz }</script>
[%- END; # want_dojo -%]
+[% IF eresource_click_track.enabled == "t"; %]
+<script type="module">
+ import {EresourceClickTrack} from '[% ctx.media_prefix %]/js/ui/default/opac/eresource_click_tracker.module.js';
+ new EresourceClickTrack().setup('.uri_link');
+</script>
+[% END; # eresource_click_track %]
+
[%- IF ctx.max_cart_size; %]
<script >var max_cart_size = [% ctx.max_cart_size %];</script>
[%- END; %]
diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/record/summary.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/record/summary.tt2
index d3932ec496..57e781ab71 100755
--- a/Open-ILS/src/templates-bootstrap/opac/parts/record/summary.tt2
+++ b/Open-ILS/src/templates-bootstrap/opac/parts/record/summary.tt2
@@ -534,7 +534,7 @@ ctx.metalinks.push('
[%- IF filtered_type.length > 0 -%]
<strong> [% filtered_type %] </strong>
[%- END -%]
- <a href="[% filtered_href %]" class="uri_link" property="url">
+ <a href="[% filtered_href %]" class="uri_link" property="url" data-record-id="[% ctx.bre_id %]">
[%- IF filtered_href != filtered_link;
'<span property="description">' _ filtered_link _ '</span>';
ELSE;
diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/result/table.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/result/table.tt2
index 832c00abfe..7a7cd7b06e 100755
--- a/Open-ILS/src/templates-bootstrap/opac/parts/result/table.tt2
+++ b/Open-ILS/src/templates-bootstrap/opac/parts/result/table.tt2
@@ -285,7 +285,7 @@
[% END %]
[% FOR uri IN args.uris %]
<dt>[% l('Electronic resource') %]</dt>
- <dd><a href="[% uri.href | html %]" class="uri_link" target="_blank">[% uri.link | html %]</a>
+ <dd><a href="[% uri.href | html %]" class="uri_link" data-record-id="[% rec.bre_id %]" target="_blank">[% uri.link | html %]</a>
[%- IF uri.note -%]
[%- '- <span property="description">' _ uri.note _ '</span>' %]
[%- ELSE -%]
diff --git a/Open-ILS/src/templates/opac/parts/header.tt2 b/Open-ILS/src/templates/opac/parts/header.tt2
index 804edcc6e8..19fb78e21d 100644
--- a/Open-ILS/src/templates/opac/parts/header.tt2
+++ b/Open-ILS/src/templates/opac/parts/header.tt2
@@ -137,6 +137,8 @@
want_dojo = 1;
END;
+ eresource_click_track = ctx.get_cgf("opac.eresources.link_click_tracking");
+
# ... and for interfaces that require manual trigger of action triggers
IF can_call_action_trigger == 'true';
want_dojo = 1;
diff --git a/Open-ILS/src/templates/opac/parts/js.tt2 b/Open-ILS/src/templates/opac/parts/js.tt2
index 5c6dcc30a8..1766befc88 100644
--- a/Open-ILS/src/templates/opac/parts/js.tt2
+++ b/Open-ILS/src/templates/opac/parts/js.tt2
@@ -208,6 +208,13 @@ var aou_hash = {
<script type="text/javascript">if ($('client_tz_id')) { $('client_tz_id').value = OpenSRF.tz }</script>
[%- END; # want_dojo -%]
+[% IF eresource_click_track.enabled == "t"; %]
+<script type="module">
+ import {EresourceClickTrack} from '[% ctx.media_prefix %]/js/ui/default/opac/eresource_click_tracker.module.js';
+ new EresourceClickTrack().setup('.uri_link');
+</script>
+[% END; # eresource_click_track %]
+
[%- IF ctx.max_cart_size; %]
<script type="text/javascript">var max_cart_size = [% ctx.max_cart_size %];</script>
[%- END; %]
diff --git a/Open-ILS/src/templates/opac/parts/record/summary.tt2 b/Open-ILS/src/templates/opac/parts/record/summary.tt2
index 530c10be66..2876c8e31e 100644
--- a/Open-ILS/src/templates/opac/parts/record/summary.tt2
+++ b/Open-ILS/src/templates/opac/parts/record/summary.tt2
@@ -232,7 +232,7 @@ IF num_uris > 0;
[%- IF filtered_type.length > 0 -%]
<strong> [% filtered_type %] </strong>
[%- END -%]
- <a href="[% filtered_href %]" class="uri_link" property="url" target="_blank" rel="noopener">
+ <a href="[% filtered_href %]" class="uri_link" property="url" target="_blank" rel="noopener" data-record-id="[% ctx.bre_id %]">
[%- IF filtered_href != filtered_link;
'<span property="description">' _ filtered_link _ '</span>';
ELSE;
diff --git a/Open-ILS/src/templates/opac/parts/result/table.tt2 b/Open-ILS/src/templates/opac/parts/result/table.tt2
index 1b23e074e7..c9e2c62bad 100644
--- a/Open-ILS/src/templates/opac/parts/result/table.tt2
+++ b/Open-ILS/src/templates/opac/parts/result/table.tt2
@@ -351,7 +351,7 @@ END;
<td valign='top'>
<strong>[% l('Electronic resource') %]</strong>
</td>
- <td><a href="[% uri.href | html %]" class="uri_link" target="_blank" rel="noopener">[% uri.link | html %]</a>
+ <td><a href="[% uri.href | html %]" class="uri_link" target="_blank" data-record-id="[% rec.bre_id %]" rel="noopener">[% uri.link | html %]</a>
[%- IF uri.note -%]
[%- '- <span property="description">' _ uri.note _ '</span>' %]
[%- ELSE -%]
diff --git a/Open-ILS/web/js/ui/default/opac/eresource_click_tracker.module.js b/Open-ILS/web/js/ui/default/opac/eresource_click_tracker.module.js
new file mode 100644
index 0000000000..efdb8dd902
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/opac/eresource_click_tracker.module.js
@@ -0,0 +1,14 @@
+export class EresourceClickTrack {
+ setup(selector) {
+ if(window.navigator.sendBeacon) {
+ document.querySelectorAll(selector).forEach(link => {
+ link.addEventListener('click', () => {
+ const data = new FormData();
+ data.append('record_id', link.getAttribute('data-record-id'));
+ data.append('url', link.getAttribute('href'));
+ window.navigator.sendBeacon('/opac/extras/eresource_link_click_track', data);
+ });
+ });
+ }
+ }
+}
diff --git a/Open-ILS/web/opac/tests/eresource_click_tracker.spec.module.js b/Open-ILS/web/opac/tests/eresource_click_tracker.spec.module.js
new file mode 100644
index 0000000000..0b69506ca4
--- /dev/null
+++ b/Open-ILS/web/opac/tests/eresource_click_tracker.spec.module.js
@@ -0,0 +1,26 @@
+import { EresourceClickTrack } from "../../js/ui/default/opac/eresource_click_tracker.module.js";
+import { JSDOM } from '../deps/node_modules/jsdom/lib/api.js';
+
+describe("eresourceClickTrack", () => {
+ it("sends a beacon on click", () => {
+ global.window = new JSDOM(`<!DOCTYPE html><body><a href="https://my-database" data-record-id="12345" id="link">Click here</a></body>`).window;
+ Object.defineProperty(global.window, 'navigator', {
+ value: {sendBeacon: () => Promise.resolve()}
+ })
+ spyOn(global.window.navigator, 'sendBeacon');
+ global.document = global.window.document;
+
+ const expectedData = new FormData();
+ expectedData.append('record_id', 12345);
+ expectedData.append('url', 'https://my-database');
+
+ new EresourceClickTrack().setup('#link');
+ const clickEvent = new global.window.Event( 'click', { bubbles: true } )
+ global.document.querySelector('#link').dispatchEvent(clickEvent);
+
+ expect(global.window.navigator.sendBeacon).toHaveBeenCalledWith(
+ '/opac/extras/eresource_link_click_track',
+ expectedData
+ );
+ });
+});
commit 72a8cc4bb0a25b1521e79e241a8ffe7401c164bd
Author: Jane Sandberg <sandbergja(a)gmail.com>
Date: Wed Mar 6 17:24:11 2024 -0800
LP1895695: Add perl and apache for the eresource link click feature
Signed-off-by: Jane Sandberg <sandbergja(a)gmail.com>
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
diff --git a/Open-ILS/examples/apache_24/eg_vhost.conf.in b/Open-ILS/examples/apache_24/eg_vhost.conf.in
index 906bb841d5..857bbbeaad 100644
--- a/Open-ILS/examples/apache_24/eg_vhost.conf.in
+++ b/Open-ILS/examples/apache_24/eg_vhost.conf.in
@@ -85,6 +85,14 @@ OSRFTranslatorConfig @sysconfdir@/opensrf_core.xml
Require all granted
</Location>
+# Eresource link click tracker
+<Location /opac/extras/eresource_link_click_track>
+ SetHandler perl-script
+ PerlHandler OpenILS::WWW::EResourceLinkClick
+ PerlSendHeader On
+ Require all granted
+</Location>
+
# Flattener service
<Location /opac/extras/flattener>
SetHandler perl-script
diff --git a/Open-ILS/examples/crontab.example b/Open-ILS/examples/crontab.example
index 9f94a7b863..057846f729 100644
--- a/Open-ILS/examples/crontab.example
+++ b/Open-ILS/examples/crontab.example
@@ -103,5 +103,10 @@ EG_BIN_DIR = /openils/bin
# Clean out action.hold_request_reset_reason_entry table
0 1 * * * . ~/.bashrc && $EG_BIN_DIR/purge_hold_reset_reason_entries.srfsh
+# Delete old link click analytics that are no longer needed after
+# the given number of days (365 by default)
+#0 1 * * * cd /openils/bin && /usr/bin/perl ./delete_old_eresource_link_clicks.pl > /dev/null
+
+
# TODO: add other entries
diff --git a/Open-ILS/src/Makefile.am b/Open-ILS/src/Makefile.am
index f0a1d9136e..a573e41431 100644
--- a/Open-ILS/src/Makefile.am
+++ b/Open-ILS/src/Makefile.am
@@ -84,6 +84,7 @@ core_scripts = $(examples)/oils_ctl.sh \
$(supportscr)/pingest.pl \
$(supportscr)/ingest_ctl \
$(supportscr)/background_import_mgr.pl \
+ $(supportscr)/delete_old_eresource_link_clicks.pl \
$(supportscr)/edi_fetcher.pl \
$(supportscr)/edi_order_pusher.pl \
$(supportscr)/edi_pusher.pl \
diff --git a/Open-ILS/src/extras/install/Makefile.debian-bookworm b/Open-ILS/src/extras/install/Makefile.debian-bookworm
index 6236c7022f..cf41bfcd5a 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-bookworm
+++ b/Open-ILS/src/extras/install/Makefile.debian-bookworm
@@ -118,7 +118,8 @@ export CPAN_MODULES = \
Text::Levenshtein::Damerau::XS \
Pass::OTP \
Authen::WebAuthn \
- Email::Send
+ Email::Send \
+ Duadua
export CPAN_MODULES_FORCE = \
Business::Stripe \
diff --git a/Open-ILS/src/extras/install/Makefile.debian-bullseye b/Open-ILS/src/extras/install/Makefile.debian-bullseye
index d21d512d42..fbf4935b61 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-bullseye
+++ b/Open-ILS/src/extras/install/Makefile.debian-bullseye
@@ -118,7 +118,8 @@ export CPAN_MODULES = \
Email::Send \
Pass::OTP \
Authen::WebAuthn \
- Locale::Country
+ Locale::Country \
+ Duadua
export CPAN_MODULES_FORCE = \
Business::Stripe \
diff --git a/Open-ILS/src/extras/install/Makefile.debian-buster b/Open-ILS/src/extras/install/Makefile.debian-buster
index 0e69ad7566..c1ef3add99 100644
--- a/Open-ILS/src/extras/install/Makefile.debian-buster
+++ b/Open-ILS/src/extras/install/Makefile.debian-buster
@@ -118,7 +118,8 @@ export CPAN_MODULES = \
Text::Levenshtein::Damerau::XS \
Pass::OTP \
Authen::WebAuthn \
- Email::Send
+ Email::Send \
+ Duadua
export CPAN_MODULES_FORCE = \
Business::Stripe \
diff --git a/Open-ILS/src/extras/install/Makefile.fedora b/Open-ILS/src/extras/install/Makefile.fedora
index 05db2b5f22..64543ee2c7 100644
--- a/Open-ILS/src/extras/install/Makefile.fedora
+++ b/Open-ILS/src/extras/install/Makefile.fedora
@@ -92,7 +92,8 @@ export CPAN_MODULES = \
Config::General \
Pass::OTP \
Authen::WebAuthn \
- Rose::URI
+ Rose::URI \
+ Duadua
export CPAN_MODULES_FORCE = \
Business::Stripe \
diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-jammy b/Open-ILS/src/extras/install/Makefile.ubuntu-jammy
index 7d04e76bf7..90995526dd 100644
--- a/Open-ILS/src/extras/install/Makefile.ubuntu-jammy
+++ b/Open-ILS/src/extras/install/Makefile.ubuntu-jammy
@@ -117,7 +117,8 @@ export CPAN_MODULES = \
String::KeyboardDistance \
Pass::OTP \
Authen::WebAuthn \
- Text::Levenshtein::Damerau::XS
+ Text::Levenshtein::Damerau::XS \
+ Duadua
export CPAN_MODULES_FORCE = \
Business::Stripe \
diff --git a/Open-ILS/src/extras/install/Makefile.ubuntu-noble b/Open-ILS/src/extras/install/Makefile.ubuntu-noble
index f708beb01b..59f2c955ad 100644
--- a/Open-ILS/src/extras/install/Makefile.ubuntu-noble
+++ b/Open-ILS/src/extras/install/Makefile.ubuntu-noble
@@ -118,7 +118,8 @@ export CPAN_MODULES = \
String::KeyboardDistance \
Pass::OTP \
Authen::WebAuthn \
- Text::Levenshtein::Damerau::XS
+ Text::Levenshtein::Damerau::XS \
+ Duadua
export CPAN_MODULES_FORCE = \
Business::Stripe \
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EResourceLinkClick.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EResourceLinkClick.pm
new file mode 100644
index 0000000000..308de5a63d
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/EResourceLinkClick.pm
@@ -0,0 +1,89 @@
+package OpenILS::Application::EResourceLinkClick;
+use OpenILS::Application;
+use base qw/OpenILS::Application/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use Duadua;
+use strict; use warnings;
+
+use OpenILS::Application::AppUtils;
+my $apputils = "OpenILS::Application::AppUtils";
+my $U = $apputils;
+
+use constant Success => 'Success';
+use constant NotConfigured => 'NotConfigured';
+use constant BadInput => 'BadInput';
+use constant InternalError => 'InternalError';
+
+sub add_click {
+ my ($self, $record_id, $url, $referer, $user_agent) = @_;
+
+ # We do not check auth for this editor, since links
+ # can be clicked by unauthenticated users and we
+ # still want to record those in the db
+ my $editor = new_editor;
+ return NotConfigured unless ($self->_feature_enabled($editor));
+ return BadInput if $self->_input_is_bad($record_id, $url, $referer, $user_agent);
+
+ return $self->_create_link($record_id, $url, $editor) ? Success : InternalError;
+}
+
+
+sub _create_link {
+ my ($self, $record_id, $url, $editor) = @_;
+ my $click = Fieldmapper::action::eresource_link_click->new;
+ $click->record($record_id);
+ $click->url($url);
+ $editor->xact_begin;
+ $editor->create_action_eresource_link_click($click) or return 0;
+ my $associated_courses = $editor->search_asset_course_module_course_materials({record => $record_id});
+ foreach(@{ $associated_courses }) {
+ my $course = $editor->retrieve_asset_course_module_course($_->course) or next;
+ my $click_course = Fieldmapper::action::eresource_link_click_course->new;
+ $click_course->click($click->id);
+ $click_course->course($course->id);
+ $click_course->course_name($course->name);
+ $click_course->course_number($course->course_number);
+ $editor->create_action_eresource_link_click_course($click_course);
+ }
+ $editor->xact_commit;
+ return 1;
+}
+
+sub _input_is_bad {
+ my ($self, $record_id, $url, $referer, $user_agent) = @_;
+ return 1 unless ($self->_referer_valid($referer));
+ return 1 if Duadua->new($user_agent)->is_bot;
+ return 1 unless $self->_url_exists_on_record($url, $record_id);
+ return 0;
+}
+
+sub _feature_enabled {
+ my ($self, $editor) = @_;
+ $editor->init;
+ my $flag = $editor->retrieve_config_global_flag('opac.eresources.link_click_tracking');
+ return ($flag->enabled eq 't');
+}
+
+sub _referer_valid {
+ my ($self, $referer) = @_;
+ return ($referer =~ /eg\/opac\/(record|results)/);
+}
+
+# Confirm that the URL and record ID we received from
+# the client actually match, since anybody could send
+# a request to this endpoint with mismatched data,
+# resulting in garbage for anybody running a report
+sub _url_exists_on_record {
+ my ($self, $url, $record_id) = @_;
+ my $root_org = $U->get_org_tree->id;
+ my $uris = $apputils->simplereq(
+ 'open-ils.search',
+ 'open-ils.search.asset.uri.retrieve_by_bib.atomic',
+ $record_id,
+ $root_org
+ );
+ my @matches = grep {$_->href eq $url} @{$uris};
+ return scalar @matches;
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm
new file mode 100644
index 0000000000..808886c118
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EResourceLinkClick.pm
@@ -0,0 +1,44 @@
+package OpenILS::WWW::EResourceLinkClick;
+
+use strict;
+use warnings;
+
+use OpenILS::Application::EResourceLinkClick;
+use Apache2::Const -compile => qw(
+ OK HTTP_BAD_REQUEST HTTP_INTERNAL_SERVER_ERROR HTTP_NOT_IMPLEMENTED
+);
+use CGI;
+
+sub handler {
+ my $r = shift;
+ my $cgi = new CGI;
+
+ my $record_id = $cgi->param('record_id') || '';
+ my $url = $cgi->param('url') || '';
+ my $referer = $cgi->http('Referer') || '';
+ my $user_agent = $cgi->http('User-Agent') || '';
+
+ my $result = OpenILS::Application::EResourceLinkClick->add_click(
+ $record_id,
+ $url,
+ $referer,
+ $user_agent
+ );
+
+ if( $result eq OpenILS::Application::EResourceLinkClick::BadInput ) {
+ return Apache2::Const::HTTP_BAD_REQUEST;
+ }
+ if( $result eq OpenILS::Application::EResourceLinkClick::InternalError ) {
+ return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
+ }
+ if( $result eq OpenILS::Application::EResourceLinkClick::NotConfigured ) {
+ return Apache2::Const::HTTP_NOT_IMPLEMENTED;
+ }
+ if( $result eq OpenILS::Application::EResourceLinkClick::Success ) {
+ $r->content_type('text/plain');
+ $r->print('click recorded');
+ return Apache2::Const::OK;
+ }
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t b/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t
new file mode 100644
index 0000000000..f3a26c1282
--- /dev/null
+++ b/Open-ILS/src/perlmods/live_t/40-eresource-link-click.t
@@ -0,0 +1,169 @@
+#!perl
+
+use strict; use warnings;
+
+use Test::More tests => 2;
+use OpenILS::Utils::TestUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+diag('Test the EResourceLinkClick module');
+
+my $script = OpenILS::Utils::TestUtils->new();
+$script->bootstrap;
+our $apputils = "OpenILS::Application::AppUtils";
+my $e = new_editor;
+$e->init;
+
+BEGIN { use_ok('OpenILS::Application::EResourceLinkClick'); }
+
+subtest('add_click', sub {
+ plan tests => 2;
+
+ subtest('when the global flag is off', sub {
+ plan tests => 2;
+ set_global_flag($e, 'f');
+
+ my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ 238,
+ 'http://example.com/ebookapi/t/001',
+ 'https://my-evergreen.org/eg/opac/results',
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
+ );
+
+ is($response, OpenILS::Application::EResourceLinkClick::NotConfigured, 'says that it is not configured');
+ assert_no_clicks_added_to_db($e);
+ });
+
+ subtest('when the global flag is on', sub {
+ plan tests => 5;
+ set_global_flag($e, 't');
+
+ subtest('when the referer did not come from the record or results page', sub {
+ plan tests => 2;
+
+ my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ 238,
+ 'http://example.com/ebookapi/t/001',
+ 'https://some-non-eg-site/bad-path',
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
+ );
+
+ is($response, OpenILS::Application::EResourceLinkClick::BadInput, 'says that the input is bad');
+ assert_no_clicks_added_to_db($e);
+ });
+
+ subtest('when user agent is a bot', sub {
+ plan tests => 2;
+ my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ 238,
+ 'http://example.com/ebookapi/t/001',
+ 'https://my-evergreen.org/eg/opac/results',
+ 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
+ );
+
+ is($response, OpenILS::Application::EResourceLinkClick::BadInput, 'says that the input is bad');
+ assert_no_clicks_added_to_db($e);
+ });
+
+ subtest('when url does not exist on the record in question', sub {
+ plan tests => 2;
+ my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ 238,
+ 'http://not-a-real-url/not-actually/on-the-record',
+ 'https://my-evergreen.org/eg/opac/results',
+ 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
+ );
+
+ is($response, OpenILS::Application::EResourceLinkClick::BadInput, 'says that the input is bad');
+ assert_no_clicks_added_to_db($e);
+ });
+
+ subtest('when input is valid', sub {
+ plan tests => 2;
+ my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ 238,
+ 'http://example.com/ebookapi/t/001',
+ 'https://my-evergreen.org/eg/opac/results',
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
+ );
+
+ is($response, OpenILS::Application::EResourceLinkClick::Success, 'says that it is successful');
+ my $rows = $e->search_action_eresource_link_click({record => 238});
+ is(scalar(@{ $rows }), 1, 'adds the click to the database');
+
+ delete_test_rows($e);
+ });
+
+ subtest('when bib record is associated with a course', sub {
+ plan tests => 4;
+
+ my $acmc = Fieldmapper::asset::course_module_course->new;
+ $acmc->id(12345);
+ $acmc->name('Introduction to cats');
+ $acmc->course_number('CATS101');
+
+ my $acmcm = Fieldmapper::asset::course_module_course_materials->new;
+ $acmcm->course(12345);
+ $acmcm->id(5678);
+ $acmcm->record(238);
+ $acmcm->temporary_record(0);
+ $e->xact_begin;
+ $e->create_asset_course_module_course( $acmc );
+ $e->create_asset_course_module_course_materials( $acmcm );
+ $e->xact_commit;
+
+ my $response = OpenILS::Application::EResourceLinkClick->add_click(
+ 238,
+ 'http://example.com/ebookapi/t/001',
+ 'https://my-evergreen.org/eg/opac/results',
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0'
+ );
+
+ is($response, OpenILS::Application::EResourceLinkClick::Success, 'says that it is successful');
+ my $rows = $e->search_action_eresource_link_click_course({course => 12345});
+ is(scalar(@{ $rows }), 1, 'adds a click course mapping to the database');
+ is($rows->[0]->course_name, 'Introduction to cats', 'adds the course name to the mapping');
+ is($rows->[0]->course_number, 'CATS101', 'adds the course number to the mapping');
+
+ delete_test_rows($e);
+ });
+ });
+});
+
+# Delete any rows that weren't deleted by the tests
+# (e.g. if there was a test failure)
+delete_test_rows($e);
+
+sub set_global_flag {
+ my ($editor, $value) = @_;
+ my $flag = $e->retrieve_config_global_flag('opac.eresources.link_click_tracking');
+ $flag->enabled($value);
+ $editor->xact_begin;
+ $editor->update_config_global_flag($flag);
+ $editor->xact_commit;
+}
+
+sub assert_no_clicks_added_to_db {
+ my $editor = shift;
+ my $rows = $e->search_action_eresource_link_click({record => 238});
+ is(scalar(@{ $rows }), 0, 'does not add any clicks to the database');
+}
+
+sub delete_test_rows {
+ my $editor = shift;
+ my $rows = $e->search_action_eresource_link_click({record => 238});
+ $editor->xact_begin;
+ foreach(@{$rows}) {
+ $editor->delete_action_eresource_link_click($_);
+ }
+ $rows = $e->search_asset_course_module_course_materials({course => 12345});
+ foreach(@{$rows}) {
+ $editor->delete_asset_course_module_course_materials($_);
+ }
+ $rows = $e->search_asset_course_module_course({id => 12345});
+ foreach(@{$rows}) {
+ $editor->delete_asset_course_module_course($_);
+ }
+ $editor->xact_commit;
+}
+
diff --git a/Open-ILS/src/support-scripts/delete_old_eresource_link_clicks.pl b/Open-ILS/src/support-scripts/delete_old_eresource_link_clicks.pl
new file mode 100755
index 0000000000..594583b9d6
--- /dev/null
+++ b/Open-ILS/src/support-scripts/delete_old_eresource_link_clicks.pl
@@ -0,0 +1,47 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use Getopt::Long;
+use OpenSRF::System;
+use OpenILS::Utils::CStoreEditor;
+use Carp;
+
+my $osrf_config = '/openils/conf/opensrf_core.xml';
+my $days = 365;
+my $help;
+
+my $ops = GetOptions(
+ 'osrf-config=s' => \$osrf_config,
+ 'days=i' => \$days,
+ 'help' => \$help
+);
+
+sub help {
+ print <<'END_HELP';
+
+
+ Usage:
+ --osrf-config [/openils/conf/opensrf_core.xml]
+
+ --days <number-of-days>
+ How many days of clicks to keep. For example, --days 7
+ will keep only the most recent week of clicks. The default
+ is 365 days.
+
+ --help
+ Show this message.
+END_HELP
+ exit 0;
+}
+
+help() if $help || !$ops;
+
+OpenSRF::System->bootstrap_client(config_file => $osrf_config);
+OpenILS::Utils::CStoreEditor::init();
+my $e = OpenILS::Utils::CStoreEditor->new;
+$e->json_query(
+ {'from' => ['action.delete_old_eresource_link_clicks', $days]}
+) or croak('Deletion failed, ' . $e->event);
+
+1;
commit ebc9613ff23a162d585ba21fc50d9bde2898595b
Author: Jane Sandberg <sandbergja(a)gmail.com>
Date: Wed Mar 6 09:47:22 2024 -0800
LP1895695: Add tables and global flag for the eresource link click feature
Signed-off-by: Jane Sandberg <sandbergja(a)gmail.com>
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 8e4f4a3206..56f0d16c2b 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -15895,6 +15895,42 @@ SELECT usr,
</permacrud>
</class>
+ <class id="aelc"
+ controller="open-ils.cstore"
+ oils_obj:fieldmapper="action::eresource_link_click"
+ oils_persist:tablename="action.eresource_link_click"
+ reporter:label="Eresource link clicks">
+ <fields oils_persist:primary="id" oils_persist:sequence="action.eresource_link_click_id_seq">
+ <field reporter:label="ID" name="id" reporter:datatype="id" />
+ <field reporter:label="Date/time of click" name="clicked_at" reporter:datatype="timestamp" oils_obj:required="true"/>
+ <field reporter:label="URL" name="url" reporter:datatype="text" oils_obj:required="true"/>
+ <field reporter:label="Record" name="record" reporter:datatype="link"/>
+ <field reporter:label="Courses" name="courses" oils_persist:virtual="true" reporter:datatype="link"/>
+ </fields>
+ <links>
+ <link field="record" reltype="has_a" key="id" map="" class="bre"/>
+ <link field="courses" reltype="has_many" key="click" map="" class="aelcc"/>
+ </links>
+ </class>
+
+ <class id="aelcc"
+ controller="open-ils.cstore"
+ oils_obj:fieldmapper="action::eresource_link_click_course"
+ oils_persist:tablename="action.eresource_link_click_course"
+ reporter:label="Eresource link click-course mapping">
+ <fields oils_persist:primary="id" oils_persist:sequence="action.eresource_link_click_course_id_seq">
+ <field reporter:label="ID" name="id" reporter:datatype="id" />
+ <field reporter:label="Click" name="click" reporter:datatype="link" oils_obj:required="true"/>
+ <field reporter:label="Course" name="course" reporter:datatype="link"/>
+ <field reporter:label="Course name" name="course_name" reporter:datatype="text" oils_obj:required="true"/>
+ <field reporter:label="Course number" name="course_number" reporter:datatype="text" oils_obj:required="true"/>
+ </fields>
+ <links>
+ <link field="click" reltype="has_a" key="id" map="" class="aelc"/>
+ <link field="course" reltype="has_a" key="id" map="" class="acmc"/>
+ </links>
+ </class>
+
<class id="acqr_inv_totals" controller="open-ils.reporter" oils_persist:readonly="true" reporter:label="Invoice Totals">
<oils_persist:source_definition><
[GIT] Evergreen ILS branch main updated. 2cd643df8a207eff8f232a97efa37471b9e65da8
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via 2cd643df8a207eff8f232a97efa37471b9e65da8 (commit)
via 6de17f25d8da669b501bcd511602fe8ba0803115 (commit)
via af69e68de814b8872db33a6ec55cdc02463ec765 (commit)
from ee6c53dfee3f7f50d5bf3b601a85ea042e582e15 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit 2cd643df8a207eff8f232a97efa37471b9e65da8
Author: Michele Morgan <mmorgan(a)noblenet.org>
Date: Fri Mar 21 16:06:22 2025 -0400
LP1815950: Release Notes
Signed-off-by: Michele Morgan <mmorgan(a)noblenet.org>
diff --git a/docs/RELEASE_NOTES_NEXT/Circulation/lp1815950_patron_reg_field_visibility b/docs/RELEASE_NOTES_NEXT/Circulation/lp1815950_patron_reg_field_visibility
new file mode 100644
index 0000000000..88a03f01fd
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Circulation/lp1815950_patron_reg_field_visibility
@@ -0,0 +1,12 @@
+== Visibilty of Fields in Patron Registration/Edit ==
+
+Enhances the ability to control display of Required, Suggested and All fields on the Patron Registration/Edit Screen.
+
+Given a field with three library settings, .show, .suggest, .require:
+
+.show = FALSE hides the field
+.show = TRUE displays the field in All, Suggested, Required (field is not required)
+.suggest = TRUE displays the field in All, Suggested
+.require = TRUE displays the field in All, Suggested, Required (field is required)
+
+.show, .suggest, .require UNSET displays the field in All, but not Suggested or Required
commit 6de17f25d8da669b501bcd511602fe8ba0803115
Author: Jason Stephenson <jason(a)sigio.com>
Date: Thu Feb 13 13:57:07 2025 -0500
LP1815950: Do not hide database-required patron fields
This commit adds a check for default visibility of patron
registration/edit fields before hiding a field when the show setting
is false. This is done to prevent database-required fields from being
hidden and blocking the saving of form data.
The upshot is that all database-required fields should have a default
visibitly of 3, for "required." An audit of the default visibility
array has NOT been done.
Signed-off-by: Jason Stephenson <jason(a)sigio.com>
Signed-off-by: Michele Morgan <mmorgan(a)noblenet.org>
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js b/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
index aefc249d4f..94c655de7d 100644
--- a/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
@@ -1682,7 +1682,11 @@ function($scope , $routeParams , $q , $uibModal , $window , egCore ,
} else if ($scope.org_settings[sho_set] === false){
// hide the field if the 'show' setting is explicitly false (not undefined)
- field_visibility[field_key] = -1;
+ if (default_field_visibility[field_key] === undefined
+ || default_field_visibility[field_key] < 3) {
+ // Only hide if not a database-required field
+ field_visibility[field_key] = -1;
+ }
}
}
commit af69e68de814b8872db33a6ec55cdc02463ec765
Author: a. bellenir <abelleni(a)grpl.org>
Date: Thu Feb 14 16:16:53 2019 -0500
LP1815950: Hide patron registration/edit fields
When org unit settings like au.prefix.show are set to true, the field
is made visible when the user shows "Suggested" or "Required"
fields. Setting au.prefix.show to false had no effect.
This change will hide the field entirely (even from "All Fields") if
the relevant '.show' setting is explicitly false (not just undefined)
by setting a negative value in field_visibility[field_key].
Signed-off-by: a. bellenir <abelleni(a)grpl.org>
Signed-off-by: Jason Stephenson <jason(a)sigio.com>
Signed-off-by: Michele Morgan <mmorgan(a)noblenet.org>
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js b/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
index 47da02d49b..aefc249d4f 100644
--- a/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
@@ -1679,6 +1679,10 @@ function($scope , $routeParams , $q , $uibModal , $window , egCore ,
} else if ($scope.org_settings[sug_set]) {
field_visibility[field_key] = 1;
+
+ } else if ($scope.org_settings[sho_set] === false){
+ // hide the field if the 'show' setting is explicitly false (not undefined)
+ field_visibility[field_key] = -1;
}
}
-----------------------------------------------------------------------
Summary of changes:
Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js | 8 ++++++++
.../Circulation/lp1815950_patron_reg_field_visibility | 12 ++++++++++++
2 files changed, 20 insertions(+)
create mode 100644 docs/RELEASE_NOTES_NEXT/Circulation/lp1815950_patron_reg_field_visibility
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch main updated. ee6c53dfee3f7f50d5bf3b601a85ea042e582e15
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via ee6c53dfee3f7f50d5bf3b601a85ea042e582e15 (commit)
from 52d12d1ebb801d0d921d4332704cfecbca2338c5 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit ee6c53dfee3f7f50d5bf3b601a85ea042e582e15
Author: Andrea Buntz Neiman <abneiman(a)equinoxinitiative.org>
Date: Fri Mar 21 16:05:19 2025 -0400
Docs: edits to MFA docs to support secondary permissions
Signed-off-by: Andrea Buntz Neiman <abneiman(a)equinoxinitiative.org>
diff --git a/docs/modules/admin/pages/multi_factor_authentication.adoc b/docs/modules/admin/pages/multi_factor_authentication.adoc
index 103d268c50..61404ef126 100644
--- a/docs/modules/admin/pages/multi_factor_authentication.adoc
+++ b/docs/modules/admin/pages/multi_factor_authentication.adoc
@@ -3,7 +3,7 @@
Multi Factor Authentication (MFA) is available in Evergreen for staff accounts. The intention is to provide a configurable secondary factor to confirm a staff login in order to provide a higher level of staff login security.
-MFA is established on User Permission Groups, and numerous configuration options are available and described in detail in the sections below.
+MFA is established on User Permission Groups, and numerous configuration options are available and described in detail in the sections below. If so enabled, MFA will check both Primary and any Secondary Permission Groups to which a user belongs.
Configuration options include:
@@ -114,26 +114,29 @@ A new OpenSRF application, `open-ils.auth_mfa`, must be configured and running,
[,xml]
----
<app_settings>
- <enabled>true</enabled>
- <factors>
- <totp>
- <enabled>true</enabled>
- <fuzziness>1</fuzziness>
- </totp>
- <sms>
- <enabled>true</enabled>
- </sms>
- <email>
- <enabled>true</enabled>
- </email>
- <webauthn>
- <enabled>true</enabled>
- </webauthn>
- </factors>
- </app_settings>
-----
-
-If the top-level `<enabled/>` element contains true, then MFA will be generally available. Each potential MFA factor must also be enabled separately, with their own `<enabled/>` element containing true.
+ <!-- 'enabled' is the master switch; set to 'true' to enable MFA -->
+ <enabled>true</enabled>
+ <!-- set 'honor_secondary_groups' to to 'true' allow secondary group membership to act in the same way as profile group for MFA availability and factor list -->
+ <honor_secondary_groups>false</honor_secondary_groups>
+ <factors>
+ <totp>
+ <enabled>true</enabled>
+ <fuzziness>1</fuzziness>
+ </totp>
+ <sms>
+ <enabled>false</enabled>
+ </sms>
+ <email>
+ <enabled>false</enabled>
+ </email>
+ <webauthn>
+ <enabled>true</enabled>
+ </webauthn>
+ </factors>
+</app_settings>
+----
+
+If the top-level `<enabled/>` element contains true, then MFA will be generally available. Each potential MFA factor must also be enabled separately, with their own `<enabled/>` element containing true. The `honor_secondary_groups` element is false by default, and it needs to be set to true if secondary permission groups should be consulted by MFA.
The TOTP, SMS, and email factors can make use of the `<fuzziness/>` element, which tells Evergreen how many timeout periods to look in the past and the future when verifying the one-time code for those factors. This defaults to 1 for all three factors, so that, for instance, a user using the Google Authenticator app for TOTP verification will have up to 90 seconds to enter a code, even though the codes change every 30 seconds. This setting helps account for unsynchronized server and client device clocks, as well as allowing Evergreen to be more forgiving for users that may take more than the average amount of time finding and then entering the one-time code.
-----------------------------------------------------------------------
Summary of changes:
.../admin/pages/multi_factor_authentication.adoc | 45 ++++++++++++----------
1 file changed, 24 insertions(+), 21 deletions(-)
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch rel_3_13 updated. e8f9423b8d3d393104e364b0b3ff535c4c55c912
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, rel_3_13 has been updated
via e8f9423b8d3d393104e364b0b3ff535c4c55c912 (commit)
from 00d04233dbc835ea70d2cf433f277148dc365910 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit e8f9423b8d3d393104e364b0b3ff535c4c55c912
Author: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Date: Fri Mar 14 04:18:49 2025 +0000
LP2018839 Clarify "name" label in create PO form
Clarifies what the user is being asked to do with the name field in the create PO form.
* Label changed from "Name (optional)" to "PO Name (optional)"
* Explanation added about POs being numbered automatically if a name is
not provided.
* aria-describedby attribute added, listing both the new explanation and
the error message shown if a duplicate name is entered (error first)
Release-note: Clarify what the "name" label in create PO form refers to
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
index 3d58929dc6..a96b849bb0 100644
--- a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
@@ -10,23 +10,28 @@
</span>
<hr class="p-1" />
</div>
- <div class="form-group">
+ <div class="form-group mb-3">
<label class="form-label" for="order-agency-input" i18n>Ordering Agency</label>
<eg-org-select (onChange)="orgChange($event)" domId="order-agency-input"
[limitPerms]="['CREATE_PURCHASE_ORDER']">
</eg-org-select>
</div>
- <div class="form-group">
- <label class="form-label" for="name-input" i18n>Name (optional)</label>
+ <div class="form-group mb-3">
+ <label class="form-label" for="name-input" i18n>PO Name (optional)</label>
<input id="name-input" class="form-control" type="text" [ngModel]="poName"
(ngModelChange)="poName = $event; checkDuplicatePoName()"
+ aria-describedby="name-dupe-error name-auto-number"
/>
+ <p class="form-text" id="name-auto-number">
+ You may enter a name for this PO for future reference.
+ POs without a name will be referenced by number.
+ </p>
</div>
- <div *ngIf="dupeResults.dupeFound" class="alert alert-warning" i18n>
+ <div *ngIf="dupeResults.dupeFound" id="name-dupe-error" class="alert alert-warning" i18n>
This name is already in used by another PO:
<a target="_blank" routerLink="/staff/acq/po/{{dupeResults.dupePoId}}">View PO</a>
</div>
- <div class="form-group">
+ <div class="form-group mb-3">
<label class="form-label" for="name-input" i18n>Provider</label>
<eg-combobox domId="provider-input" [(ngModel)]="provider"
[asyncSupportsEmptyTermClick]="true"
@@ -34,14 +39,14 @@
[idlQueryAnd]="{active: 't'}" idlClass="acqpro">
</eg-combobox>
</div>
- <div class="form-group form-check">
+ <div class="form-group form-check mb-3">
<input type="checkbox" class="form-check-input"
[(ngModel)]="prepaymentRequired" id="prepayment-required">
<label class="form-label form-check-label" for="prepayment-required" i18n>
Prepayment Required
</label>
</div>
- <div class="form-group form-check" *ngIf="lineitems.length">
+ <div class="form-group form-check mb-3" *ngIf="lineitems.length">
<input type="checkbox" class="form-check-input"
[(ngModel)]="createAssets" id="create-assets">
<label class="form-label form-check-label" for="create-assets" i18n>
-----------------------------------------------------------------------
Summary of changes:
.../eg2/src/app/staff/acq/po/create.component.html | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch rel_3_14 updated. 4acf0c86cd2bde51cc94173de43ec99bf08891c1
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, rel_3_14 has been updated
via 4acf0c86cd2bde51cc94173de43ec99bf08891c1 (commit)
from 8e686c4bc0d9c4b8c7a8cc65876caa75611799c6 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit 4acf0c86cd2bde51cc94173de43ec99bf08891c1
Author: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Date: Fri Mar 14 04:18:49 2025 +0000
LP2018839 Clarify "name" label in create PO form
Clarifies what the user is being asked to do with the name field in the create PO form.
* Label changed from "Name (optional)" to "PO Name (optional)"
* Explanation added about POs being numbered automatically if a name is
not provided.
* aria-describedby attribute added, listing both the new explanation and
the error message shown if a duplicate name is entered (error first)
Release-note: Clarify what the "name" label in create PO form refers to
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
index 3d58929dc6..a96b849bb0 100644
--- a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
@@ -10,23 +10,28 @@
</span>
<hr class="p-1" />
</div>
- <div class="form-group">
+ <div class="form-group mb-3">
<label class="form-label" for="order-agency-input" i18n>Ordering Agency</label>
<eg-org-select (onChange)="orgChange($event)" domId="order-agency-input"
[limitPerms]="['CREATE_PURCHASE_ORDER']">
</eg-org-select>
</div>
- <div class="form-group">
- <label class="form-label" for="name-input" i18n>Name (optional)</label>
+ <div class="form-group mb-3">
+ <label class="form-label" for="name-input" i18n>PO Name (optional)</label>
<input id="name-input" class="form-control" type="text" [ngModel]="poName"
(ngModelChange)="poName = $event; checkDuplicatePoName()"
+ aria-describedby="name-dupe-error name-auto-number"
/>
+ <p class="form-text" id="name-auto-number">
+ You may enter a name for this PO for future reference.
+ POs without a name will be referenced by number.
+ </p>
</div>
- <div *ngIf="dupeResults.dupeFound" class="alert alert-warning" i18n>
+ <div *ngIf="dupeResults.dupeFound" id="name-dupe-error" class="alert alert-warning" i18n>
This name is already in used by another PO:
<a target="_blank" routerLink="/staff/acq/po/{{dupeResults.dupePoId}}">View PO</a>
</div>
- <div class="form-group">
+ <div class="form-group mb-3">
<label class="form-label" for="name-input" i18n>Provider</label>
<eg-combobox domId="provider-input" [(ngModel)]="provider"
[asyncSupportsEmptyTermClick]="true"
@@ -34,14 +39,14 @@
[idlQueryAnd]="{active: 't'}" idlClass="acqpro">
</eg-combobox>
</div>
- <div class="form-group form-check">
+ <div class="form-group form-check mb-3">
<input type="checkbox" class="form-check-input"
[(ngModel)]="prepaymentRequired" id="prepayment-required">
<label class="form-label form-check-label" for="prepayment-required" i18n>
Prepayment Required
</label>
</div>
- <div class="form-group form-check" *ngIf="lineitems.length">
+ <div class="form-group form-check mb-3" *ngIf="lineitems.length">
<input type="checkbox" class="form-check-input"
[(ngModel)]="createAssets" id="create-assets">
<label class="form-label form-check-label" for="create-assets" i18n>
-----------------------------------------------------------------------
Summary of changes:
.../eg2/src/app/staff/acq/po/create.component.html | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch main updated. 52d12d1ebb801d0d921d4332704cfecbca2338c5
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via 52d12d1ebb801d0d921d4332704cfecbca2338c5 (commit)
from 48bdcabf928785e0db9aa649c0b6a4714c3ffcdc (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit 52d12d1ebb801d0d921d4332704cfecbca2338c5
Author: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Date: Fri Mar 14 04:18:49 2025 +0000
LP2018839 Clarify "name" label in create PO form
Clarifies what the user is being asked to do with the name field in the create PO form.
* Label changed from "Name (optional)" to "PO Name (optional)"
* Explanation added about POs being numbered automatically if a name is
not provided.
* aria-describedby attribute added, listing both the new explanation and
the error message shown if a duplicate name is entered (error first)
Release-note: Clarify what the "name" label in create PO form refers to
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
index 3d58929dc6..a96b849bb0 100644
--- a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
@@ -10,23 +10,28 @@
</span>
<hr class="p-1" />
</div>
- <div class="form-group">
+ <div class="form-group mb-3">
<label class="form-label" for="order-agency-input" i18n>Ordering Agency</label>
<eg-org-select (onChange)="orgChange($event)" domId="order-agency-input"
[limitPerms]="['CREATE_PURCHASE_ORDER']">
</eg-org-select>
</div>
- <div class="form-group">
- <label class="form-label" for="name-input" i18n>Name (optional)</label>
+ <div class="form-group mb-3">
+ <label class="form-label" for="name-input" i18n>PO Name (optional)</label>
<input id="name-input" class="form-control" type="text" [ngModel]="poName"
(ngModelChange)="poName = $event; checkDuplicatePoName()"
+ aria-describedby="name-dupe-error name-auto-number"
/>
+ <p class="form-text" id="name-auto-number">
+ You may enter a name for this PO for future reference.
+ POs without a name will be referenced by number.
+ </p>
</div>
- <div *ngIf="dupeResults.dupeFound" class="alert alert-warning" i18n>
+ <div *ngIf="dupeResults.dupeFound" id="name-dupe-error" class="alert alert-warning" i18n>
This name is already in used by another PO:
<a target="_blank" routerLink="/staff/acq/po/{{dupeResults.dupePoId}}">View PO</a>
</div>
- <div class="form-group">
+ <div class="form-group mb-3">
<label class="form-label" for="name-input" i18n>Provider</label>
<eg-combobox domId="provider-input" [(ngModel)]="provider"
[asyncSupportsEmptyTermClick]="true"
@@ -34,14 +39,14 @@
[idlQueryAnd]="{active: 't'}" idlClass="acqpro">
</eg-combobox>
</div>
- <div class="form-group form-check">
+ <div class="form-group form-check mb-3">
<input type="checkbox" class="form-check-input"
[(ngModel)]="prepaymentRequired" id="prepayment-required">
<label class="form-label form-check-label" for="prepayment-required" i18n>
Prepayment Required
</label>
</div>
- <div class="form-group form-check" *ngIf="lineitems.length">
+ <div class="form-group form-check mb-3" *ngIf="lineitems.length">
<input type="checkbox" class="form-check-input"
[(ngModel)]="createAssets" id="create-assets">
<label class="form-label form-check-label" for="create-assets" i18n>
-----------------------------------------------------------------------
Summary of changes:
.../eg2/src/app/staff/acq/po/create.component.html | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch main updated. 48bdcabf928785e0db9aa649c0b6a4714c3ffcdc
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via 48bdcabf928785e0db9aa649c0b6a4714c3ffcdc (commit)
via ef92c6c4f1659796cb0a5a28aee83423cf156a15 (commit)
via b67a5ce2fe5655e121b89ae2fb333bfeedd021fd (commit)
from b145aaa6229bc6429b77d458ff512e07c84b8a55 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit 48bdcabf928785e0db9aa649c0b6a4714c3ffcdc
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Fri Mar 21 11:25:38 2025 -0400
Stamping upgrade scripts for new-tab/no-new-tabs
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index ded4f9a771..27f620b41c 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1464', :eg_version); -- sandbergja/miker
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1466', :eg_version); -- sleary/miker
CREATE TABLE config.bib_source (
id SERIAL PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.staff-portal-urls-newtab.sql b/Open-ILS/src/sql/Pg/upgrade/1465.schema.staff-portal-urls-newtab.sql
similarity index 59%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.staff-portal-urls-newtab.sql
rename to Open-ILS/src/sql/Pg/upgrade/1465.schema.staff-portal-urls-newtab.sql
index d5c3da47d3..bc0ec71260 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.staff-portal-urls-newtab.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1465.schema.staff-portal-urls-newtab.sql
@@ -1,6 +1,6 @@
BEGIN;
-SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1465', :eg_version);
ALTER TABLE config.ui_staff_portal_page_entry
ADD COLUMN url_newtab boolean;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.data.ws-setting-no-newtabs.sql b/Open-ILS/src/sql/Pg/upgrade/1466.data.ws-setting-no-newtabs.sql
similarity index 88%
rename from Open-ILS/src/sql/Pg/upgrade/YYYY.data.ws-setting-no-newtabs.sql
rename to Open-ILS/src/sql/Pg/upgrade/1466.data.ws-setting-no-newtabs.sql
index e62f8e2485..ffc6570194 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.data.ws-setting-no-newtabs.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1466.data.ws-setting-no-newtabs.sql
@@ -1,6 +1,6 @@
BEGIN;
-SELECT evergreen.upgrade_deps_block_check('YYYY', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1466', :eg_version);
INSERT into config.workstation_setting_type
(name, grp, label, description, datatype)
commit ef92c6c4f1659796cb0a5a28aee83423cf156a15
Author: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Date: Sun Oct 6 22:03:15 2024 +0000
LP2015351 WS setting for links opening in new tabs
Adds a workstation setting to choose whether links are allowed to open
in new tabs. If the user chooses this setting, links' target attributes
will be removed automatically throughout the Angular client.
Release-note: Add workstation setting for opening links in new tabs
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Signed-off-by: Gina Monti <gmonti(a)biblio.org>
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
diff --git a/Open-ILS/src/eg2/src/app/app.component.ts b/Open-ILS/src/eg2/src/app/app.component.ts
index e98d3b8031..d42cb105c3 100644
--- a/Open-ILS/src/eg2/src/app/app.component.ts
+++ b/Open-ILS/src/eg2/src/app/app.component.ts
@@ -19,6 +19,9 @@ export class BaseComponent implements AfterViewChecked {
}
ngAfterViewChecked(): void {
+ document.querySelectorAll('.user-pref-no-new-tabs a[target="_blank"]').forEach((a) => {
+ a.removeAttribute('target');
+ });
document.querySelectorAll('a[target="_blank"]').forEach((a) => {
if (!a.getAttribute('aria-describedby')) {
a.setAttribute('aria-describedby', 'link-opens-newtab');
diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.ts b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
index c2eb70f5c7..173f942dd0 100644
--- a/Open-ILS/src/eg2/src/app/staff/nav.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/nav.component.ts
@@ -34,6 +34,7 @@ export class StaffNavComponent implements OnInit, OnDestroy {
mfaAllowed: boolean;
showAngularCirc = false;
maxRecentPatrons = 1;
+ disable_links_newtabs = false;
// Menu toggle
isMenuCollapsed = true;
@@ -97,6 +98,12 @@ export class StaffNavComponent implements OnInit, OnDestroy {
.then(settings => this.maxRecentPatrons =
settings['ui.staff.max_recent_patrons'] ?? 1);
+ this.org.settings('ui.staff.disable_links_newtabs')
+ .then(settings => {
+ this.disable_links_newtabs = Boolean(settings['ui.staff.disable_links_newtabs']) ?? false;
+ this.setNewTabsPref(this.disable_links_newtabs);
+ });
+
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)');
darkModePreference.addEventListener('change', () => {
// Don't change color mode while printing
@@ -176,6 +183,17 @@ export class StaffNavComponent implements OnInit, OnDestroy {
this.setColorMode();
}
+ setNewTabsPref(disable_links_newtabs: boolean) {
+ // classname used in app.component.ts to dynamically remove target attributes
+ const staffContainer = document.getElementById('staff-content-container');
+ if (disable_links_newtabs) {
+ staffContainer.classList.add('user-pref-no-new-tabs');
+ }
+ else {
+ staffContainer.classList.remove('user-pref-no-new-tabs');
+ }
+ }
+
setLocale(locale: any) {
this.locale.setLocale(locale.code());
}
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 9939428866..beefdd0cd9 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -25041,3 +25041,17 @@ VALUES (
'string'
);
+INSERT into config.workstation_setting_type
+ (name, grp, label, description, datatype)
+VALUES (
+ 'ui.staff.disable_links_newtabs',
+ 'gui',
+ oils_i18n_gettext('ui.staff.disable_links_newtabs',
+ 'Staff Client: no new tabs',
+ 'coust', 'label'),
+ oils_i18n_gettext('ui.staff.disable_links_newtabs',
+ 'Prevents links in the staff interface from opening in new tabs or windows.',
+ 'coust', 'description'),
+ 'bool'
+);
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.data.ws-setting-no-newtabs.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.ws-setting-no-newtabs.sql
new file mode 100644
index 0000000000..e62f8e2485
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.ws-setting-no-newtabs.sql
@@ -0,0 +1,19 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('YYYY', :eg_version);
+
+INSERT into config.workstation_setting_type
+ (name, grp, label, description, datatype)
+VALUES (
+ 'ui.staff.disable_links_newtabs',
+ 'gui',
+ oils_i18n_gettext('ui.staff.disable_links_newtabs',
+ 'Staff Client: no new tabs',
+ 'coust', 'label'),
+ oils_i18n_gettext('ui.staff.disable_links_newtabs',
+ 'Prevents links in the staff interface from opening in new tabs or windows.',
+ 'coust', 'description'),
+ 'bool'
+);
+
+COMMIT;
diff --git a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
index d85c87d19f..6b8aeeaa42 100644
--- a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
+++ b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
@@ -184,7 +184,7 @@ function($scope , $window , $location , egCore , egConfirmDialog) {
$scope.grid_density = val;
});
- egCore.hatch.getItem('eg.admin.disable_links_newtabs').then(function(val) {
+ egCore.hatch.getItem('ui.staff.disable_links_newtabs').then(function(val) {
$scope.disable_links_newtabs = val;
});
@@ -239,9 +239,9 @@ function($scope , $window , $location , egCore , egConfirmDialog) {
$scope.apply_disable_links_newtabs = function() {
if ($scope.disable_links_newtabs) {
- egCore.hatch.setItem('eg.admin.disable_links_newtabs', true);
+ egCore.hatch.setItem('ui.staff.disable_links_newtabs', true);
} else {
- egCore.hatch.removeItem('eg.admin.disable_links_newtabs');
+ egCore.hatch.removeItem('ui.staff.disable_links_newtabs');
}
}
commit b67a5ce2fe5655e121b89ae2fb333bfeedd021fd
Author: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Date: Wed Sep 25 19:36:15 2024 +0000
LP2015351 Staff portal entry links in new tabs
Adds a checkbox to allow staff portal entry links to open in new tabs.
Enforces an ARIA description that informs the user that links will be
opening in a new tab, unless the link already has an ARIA description.
Includes styling to add a small box/arrow icon to links that open in new
tabs.
Release-note: Allows staff portal entry links to open new tabs; styling
for links that open in new tabs
Signed-off-by: Stephanie Leary <stephanie.leary(a)equinoxoli.org>
Signed-off-by: Gina Monti <gmonti(a)biblio.org>
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index c1ed452927..8e4f4a3206 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -14382,6 +14382,7 @@ SELECT usr,
<field name="label" reporter:label="Entry Label" reporter:datatype="text"/>
<field name="image_url" reporter:label="Entry Image URL" reporter:datatype="text"/>
<field name="target_url" reporter:label="Entry Target URL" reporter:datatype="text"/>
+ <field name="url_newtab" reporter:label="URL Opens in New Tab" reporter:datatype="bool"/>
<field name="entry_text" reporter:label="Entry Text" reporter:datatype="text"/>
<field name="owner" reporter:label="Owner" reporter:datatype="link" oils_obj:required="true"/>
</fields>
diff --git a/Open-ILS/src/eg2/src/app/app.component.ts b/Open-ILS/src/eg2/src/app/app.component.ts
index b7715a8627..e98d3b8031 100644
--- a/Open-ILS/src/eg2/src/app/app.component.ts
+++ b/Open-ILS/src/eg2/src/app/app.component.ts
@@ -1,4 +1,4 @@
-import {Component} from '@angular/core';
+import {Component, AfterViewChecked} from '@angular/core';
import {Router, NavigationEnd} from '@angular/router';
import {DialogComponent} from '@eg/share/dialog/dialog.component';
@@ -7,7 +7,7 @@ import {DialogComponent} from '@eg/share/dialog/dialog.component';
template: '<router-outlet></router-outlet>'
})
-export class BaseComponent {
+export class BaseComponent implements AfterViewChecked {
constructor(private router: Router) {
this.router.events.subscribe(routeEvent => {
@@ -18,6 +18,13 @@ export class BaseComponent {
});
}
+ ngAfterViewChecked(): void {
+ document.querySelectorAll('a[target="_blank"]').forEach((a) => {
+ if (!a.getAttribute('aria-describedby')) {
+ a.setAttribute('aria-describedby', 'link-opens-newtab');
+ }
+ });
+ }
}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts
index f0dd16c197..58befbe031 100644
--- a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts
@@ -25,7 +25,7 @@ import {merge, Observable, EMPTY} from 'rxjs';
export class AdminStaffPortalPageComponent extends AdminPageComponent implements OnInit {
idlClass = 'cusppe';
- fieldOrder = 'label,entry_type,target_url,entry_text,image_url,page_col,col_pos,owner,id';
+ fieldOrder = 'label,entry_type,target_url,url_newtab,entry_text,image_url,page_col,col_pos,owner,id';
classLabel: string;
refreshSelected: (idlThings: IdlObject[]) => void;
diff --git a/Open-ILS/src/eg2/src/app/staff/splash.component.css b/Open-ILS/src/eg2/src/app/staff/splash.component.css
index e5951c68e6..ea8b4de111 100644
--- a/Open-ILS/src/eg2/src/app/staff/splash.component.css
+++ b/Open-ILS/src/eg2/src/app/staff/splash.component.css
@@ -17,6 +17,7 @@
.card {
background-color: var(--bs-card-bg);
border-color: light-dark(var(--border), var(--splash-card-header-border));
+ margin-block-end: 2rem;
}
.card-body, .card-body .list-group-item {
diff --git a/Open-ILS/src/eg2/src/app/staff/splash.component.html b/Open-ILS/src/eg2/src/app/staff/splash.component.html
index 929ee80f49..30fc9bb314 100644
--- a/Open-ILS/src/eg2/src/app/staff/splash.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/splash.component.html
@@ -39,7 +39,7 @@
<div class="list-group">
<ng-container *ngFor="let entry of portalEntries[i]">
<div class="list-group-item border-0 p-2" *ngIf="entry.entry_type() === 'menuitem'">
- <a href="{{entry.target_url()}}" i18n>
+ <a href="{{entry.target_url()}}" [target]="entry.url_newtab() ? '_blank' : null" i18n>
<img src="{{entry.image_url()}}" alt="" role="presentation"/>
{{entry.label()}}
</a>
@@ -62,7 +62,7 @@
</div>
</div>
<div class="list-group-item border-0 p-2" *ngIf="entry.entry_type() === 'link'">
- <a target="_top" href="{{entry.target_url()}}" i18n>
+ <a [target]="entry.url_newtab() ? '_blank' : '_top'" href="{{entry.target_url()}}" i18n>
<img src="{{entry.image_url()}}" alt="" role="presentation"/>
{{entry.label()}}
</a>
diff --git a/Open-ILS/src/eg2/src/app/staff/staff.component.html b/Open-ILS/src/eg2/src/app/staff/staff.component.html
index 767f514353..03866dac8e 100644
--- a/Open-ILS/src/eg2/src/app/staff/staff.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/staff.component.html
@@ -19,6 +19,9 @@
(click)="fireContextMenuEvent()">Display Context Menu
</button>
+<!-- global notice of opening in new tab for links' aria-describedby -->
+<span id="link-opens-newtab" class="visually-hidden" i18n>Opens in new tab</span>
+
<!-- global toast alerts -->
<eg-toast></eg-toast>
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
index 377a76bd59..7bc9e65b4e 100644
--- a/Open-ILS/src/eg2/src/styles.css
+++ b/Open-ILS/src/eg2/src/styles.css
@@ -123,6 +123,13 @@ a:not(.alert-link):is(:hover, :focus, :focus-visible),
filter: brightness(1.1);
}
+a[target="_blank"]:after {
+ display: inline;
+ content: "\e89e";
+ font-family: "Material Icons";
+ line-height: inherit;
+}
+
/** BS has flex utility classes, but none for specifying flex widths.
* BS class="col" is roughly equivelent to flex-1, but col-2 is not
* equivalent to flex-2, since col-2 really means 2/12 width. */
diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index a1cb7926e2..ded4f9a771 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -1415,6 +1415,7 @@ CREATE TABLE config.ui_staff_portal_page_entry (
label TEXT,
image_url TEXT,
target_url TEXT,
+ url_newtab BOOLEAN,
entry_text TEXT,
owner INT NOT NULL -- REFERENCES actor.org_unit (id)
);
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index d0cf92515d..9939428866 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -22559,20 +22559,20 @@ VALUES
('catalogsearch', oils_i18n_gettext('catalogsearch', 'Catalog Search Box', 'cusppet', 'label'));
INSERT INTO config.ui_staff_portal_page_entry
- (id, page_col, col_pos, entry_type, label, image_url, target_url, owner)
+ (id, page_col, col_pos, entry_type, label, image_url, target_url, url_newtab, owner)
VALUES
- ( 1, 1, 0, 'header', oils_i18n_gettext( 1, 'Circulation and Patrons', 'cusppe', 'label'), NULL, NULL, 1)
-, ( 2, 1, 1, 'menuitem', oils_i18n_gettext( 2, 'Check Out Items', 'cusppe', 'label'), '/images/portal/forward.png', '/eg/staff/circ/patron/bcsearch', 1)
-, ( 3, 1, 2, 'menuitem', oils_i18n_gettext( 3, 'Check In Items', 'cusppe', 'label'), '/images/portal/back.png', '/eg/staff/circ/checkin/index', 1)
-, ( 4, 1, 3, 'menuitem', oils_i18n_gettext( 4, 'Search For Patron By Name', 'cusppe', 'label'), '/images/portal/retreivepatron.png', '/eg/staff/circ/patron/search', 1)
-, ( 5, 2, 0, 'header', oils_i18n_gettext( 5, 'Item Search and Cataloging', 'cusppe', 'label'), NULL, NULL, 1)
-, ( 6, 2, 1, 'catalogsearch', oils_i18n_gettext( 6, 'Search Catalog', 'cusppe', 'label'), NULL, NULL, 1)
-, ( 7, 2, 2, 'menuitem', oils_i18n_gettext( 7, 'Record Buckets', 'cusppe', 'label'), '/images/portal/bucket.png', '/eg2/staff/cat/bucket/record/', 1)
-, ( 8, 2, 3, 'menuitem', oils_i18n_gettext( 8, 'Item Buckets', 'cusppe', 'label'), '/images/portal/bucket.png', '/eg/staff/cat/bucket/copy/', 1)
-, ( 9, 3, 0, 'header', oils_i18n_gettext( 9, 'Administration', 'cusppe', 'label'), NULL, NULL, 1)
-, (10, 3, 1, 'link', oils_i18n_gettext(10, 'Evergreen Documentation', 'cusppe', 'label'), '/images/portal/helpdesk.png', 'https://docs.evergreen-ils.org', 1)
-, (11, 3, 2, 'menuitem', oils_i18n_gettext(11, 'Workstation Administration', 'cusppe', 'label'), '/images/portal/helpdesk.png', '/eg/staff/admin/workstation/index', 1)
-, (12, 3, 3, 'menuitem', oils_i18n_gettext(12, 'Reports', 'cusppe', 'label'), '/images/portal/reports.png', '/eg2/staff/reporter/full', 1)
+ ( 1, 1, 0, 'header', oils_i18n_gettext( 1, 'Circulation and Patrons', 'cusppe', 'label'), NULL, NULL, NULL, 1)
+, ( 2, 1, 1, 'menuitem', oils_i18n_gettext( 2, 'Check Out Items', 'cusppe', 'label'), '/images/portal/forward.png', '/eg/staff/circ/patron/bcsearch', NULL, 1)
+, ( 3, 1, 2, 'menuitem', oils_i18n_gettext( 3, 'Check In Items', 'cusppe', 'label'), '/images/portal/back.png', '/eg/staff/circ/checkin/index', NULL, 1)
+, ( 4, 1, 3, 'menuitem', oils_i18n_gettext( 4, 'Search For Patron By Name', 'cusppe', 'label'), '/images/portal/retreivepatron.png', '/eg/staff/circ/patron/search', NULL, 1)
+, ( 5, 2, 0, 'header', oils_i18n_gettext( 5, 'Item Search and Cataloging', 'cusppe', 'label'), NULL, NULL, NULL, 1)
+, ( 6, 2, 1, 'catalogsearch', oils_i18n_gettext( 6, 'Search Catalog', 'cusppe', 'label'), NULL, NULL, NULL, 1)
+, ( 7, 2, 2, 'menuitem', oils_i18n_gettext( 7, 'Record Buckets', 'cusppe', 'label'), '/images/portal/bucket.png', '/eg/staff/cat/bucket/record/', NULL, 1)
+, ( 8, 2, 3, 'menuitem', oils_i18n_gettext( 8, 'Item Buckets', 'cusppe', 'label'), '/images/portal/bucket.png', '/eg/staff/cat/bucket/copy/', NULL, 1)
+, ( 9, 3, 0, 'header', oils_i18n_gettext( 9, 'Administration', 'cusppe', 'label'), NULL, NULL, NULL, 1)
+, (10, 3, 1, 'link', oils_i18n_gettext(10, 'Evergreen Documentation', 'cusppe', 'label'), '/images/portal/helpdesk.png', 'https://docs.evergreen-ils.org', TRUE, 1)
+, (11, 3, 2, 'menuitem', oils_i18n_gettext(11, 'Workstation Administration', 'cusppe', 'label'), '/images/portal/helpdesk.png', '/eg/staff/admin/workstation/index', NULL, 1)
+, (12, 3, 3, 'menuitem', oils_i18n_gettext(12, 'Reports', 'cusppe', 'label'), '/images/portal/reports.png', '/eg2/staff/reporter/full', NULL, 1)
;
SELECT setval('config.ui_staff_portal_page_entry_id_seq', 100);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.staff-portal-urls-newtab.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.staff-portal-urls-newtab.sql
new file mode 100644
index 0000000000..d5c3da47d3
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.staff-portal-urls-newtab.sql
@@ -0,0 +1,8 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+ALTER TABLE config.ui_staff_portal_page_entry
+ADD COLUMN url_newtab boolean;
+
+COMMIT;
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
index cc4849101b..c3bc35972f 100644
--- a/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
+++ b/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
@@ -1,4 +1,4 @@
-
+[% # See Open-ILS/web/js/ui/default/staff/admin/workstation/app.js for settings %]
<div class="container admin-splash-container">
<div class="row">
@@ -73,6 +73,19 @@
</div>
</div>
+ <div class="row new-entry">
+ <div class="col-md-12">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" ng-model="disable_links_newtabs"
+ ng-change="apply_disable_links_newtabs()">
+ [% l('Prevent links from opening in new tabs?') %]
+ </label>
+ </div>
+ <p>[% l('Some links open in new tabs or windows. Screen reader users might want to select this option to force all links to open in the same window.') %]</p>
+ </div>
+ </div>
+
<div class="row new-entry">
<div class="col-md-12">
<div class="checkbox">
diff --git a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
index 3d8ed587b7..d85c87d19f 100644
--- a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
+++ b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
@@ -184,6 +184,10 @@ function($scope , $window , $location , egCore , egConfirmDialog) {
$scope.grid_density = val;
});
+ egCore.hatch.getItem('eg.admin.disable_links_newtabs').then(function(val) {
+ $scope.disable_links_newtabs = val;
+ });
+
egCore.hatch.getItem('eg.search.search_lib').then(function(val) {
$scope.search_lib = egCore.org.get(val);
});
@@ -233,6 +237,14 @@ function($scope , $window , $location , egCore , egConfirmDialog) {
console.log("New density: ", $scope.grid_density);
}
+ $scope.apply_disable_links_newtabs = function() {
+ if ($scope.disable_links_newtabs) {
+ egCore.hatch.setItem('eg.admin.disable_links_newtabs', true);
+ } else {
+ egCore.hatch.removeItem('eg.admin.disable_links_newtabs');
+ }
+ }
+
$scope.test_audio = function(sound) {
egCore.audio.play(sound);
}
-----------------------------------------------------------------------
Summary of changes:
Open-ILS/examples/fm_IDL.xml | 1 +
Open-ILS/src/eg2/src/app/app.component.ts | 14 ++++++--
.../staff-portal-page.component.ts | 2 +-
Open-ILS/src/eg2/src/app/staff/nav.component.ts | 18 ++++++++++
.../src/eg2/src/app/staff/splash.component.css | 1 +
.../src/eg2/src/app/staff/splash.component.html | 4 +--
.../src/eg2/src/app/staff/staff.component.html | 3 ++
Open-ILS/src/eg2/src/styles.css | 7 ++++
Open-ILS/src/sql/Pg/002.schema.config.sql | 3 +-
Open-ILS/src/sql/Pg/950.data.seed-values.sql | 40 +++++++++++++++-------
.../1465.schema.staff-portal-urls-newtab.sql | 8 +++++
.../Pg/upgrade/1466.data.ws-setting-no-newtabs.sql | 19 ++++++++++
.../templates/staff/admin/workstation/t_splash.tt2 | 15 +++++++-
.../js/ui/default/staff/admin/workstation/app.js | 12 +++++++
14 files changed, 127 insertions(+), 20 deletions(-)
create mode 100644 Open-ILS/src/sql/Pg/upgrade/1465.schema.staff-portal-urls-newtab.sql
create mode 100644 Open-ILS/src/sql/Pg/upgrade/1466.data.ws-setting-no-newtabs.sql
hooks/post-receive
--
Evergreen ILS
1
0

[GIT] Evergreen ILS branch main updated. b145aaa6229bc6429b77d458ff512e07c84b8a55
by Git User 21 Mar '25
by Git User 21 Mar '25
21 Mar '25
This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".
The branch, main has been updated
via b145aaa6229bc6429b77d458ff512e07c84b8a55 (commit)
from 83c3f55887e01b2405a2d8bda22140d7beab79f8 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit b145aaa6229bc6429b77d458ff512e07c84b8a55
Author: Mike Rylander <mrylander(a)gmail.com>
Date: Fri Jan 3 15:12:59 2025 -0500
LP#2080572: Support secondary permission groups for MFA configuration
This adds a new configuration file setting for the open-ils.auth_mfa
OpenSRF application, called honor_secondary_groups, which causes the
Multi-Factor Authentication system to consider all permission groups to
which a user belongs when deciding if MFA is available or required, and
which factors are configurable for that user. The previous behavior was
to consider only the Profile Group of the user when determining MFA
configuration.
The strictest interpretation of available and required are used, so that
if one group the user belongs to requires MFA, it is required for the
user.
The full set of factors enabled for all groups that the user belongs to
are presented for use by the user.
Release-note: A new `opensrf.xml` config file option, `honor_secondary_groups`,
allows secondary group membership to add MFA configuration to a user, in
addition to the main profile group. This option defaults to "false", i.e.,
secondary permission groups are not considered. If the option is turned
on, if any permission group associated with the user, including the profile,
requires MFA, MFA will be required of the user.
Signed-off-by: Mike Rylander <mrylander(a)gmail.com>
Signed-off-by: Elizabeth Davis <elizabeth.davis(a)sparkpa.org>
Signed-off-by: Galen Charlton <gmc(a)equinoxOLI.org>
diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index ec87c9804d..74b5532513 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -540,6 +540,8 @@ vim:et:ts=4:sw=4:
<app_settings>
<!-- 'enabled' is the master switch; set to 'true' to enable MFA -->
<enabled>false</enabled>
+ <!-- set 'honor_secondary_groups' to to 'true' allow secondary group membership to act in the same way as profile group for MFA availability and factor list -->
+ <honor_secondary_groups>false</honor_secondary_groups>
<factors>
<totp>
<enabled>true</enabled>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/MultiFactorAuth.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/MultiFactorAuth.pm
index 670dd9a88f..dd8d2c621e 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/MultiFactorAuth.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/MultiFactorAuth.pm
@@ -56,13 +56,16 @@ my $factor_configs;
our $cache;
our $enabled = 'false';
+our $secondary = 'false';
sub initialize {
my $conf = OpenSRF::Utils::SettingsClient->new;
my $settings = $conf->config_value( qw/apps open-ils.auth_mfa app_settings/ );
$enabled = $$settings{enabled};
+ $secondary = $$settings{honor_secondary_groups};
$logger->info("MFA enable: $enabled");
+ $logger->info("MFA honors secondary group membership: $secondary");
$factor_configs = $$settings{factors};
$factor_configs = {} if ref($factor_configs) ne 'HASH';
@@ -193,6 +196,11 @@ sub enabled {
return 1;
}
+sub secondary {
+ return 0 unless $secondary eq 'true';
+ return 1;
+}
+
__PACKAGE__->register_method(
method => "enabled_factor_list",
api_name => "open-ils.auth_mfa.enabled_factors",
@@ -331,10 +339,13 @@ sub factors_for_token {
return undef unless $usr; # no session, no MFA
$logger->info("MFA user id: ". $usr->id);
- my $grp = $e->retrieve_permission_grp_tree($usr->profile);
+ my $grp_id_list = [$usr->profile];
+ if (secondary()) {
+ push @$grp_id_list, map {$_->grp} @{$e->search_permission_usr_grp_map({usr => $usr->id})};
+ }
# check group factor list against enabled factors, return 0 if no overlap
- my $grp_ancestors = $U->get_grp_ancestors($usr->profile);
+ my $grp_ancestors = [ uniq map { @$_ } map { $U->get_grp_ancestors($_) } @$grp_id_list ];
$logger->info("MFA user groups: ". join(' ', @$grp_ancestors));
my $group_factors = $e->search_permission_group_mfa_factor_map({
@@ -476,8 +487,13 @@ sub proceed_for_token {
return 0 unless $usr; # no session, no MFA
# If MFA is not allowed for the group, say so
- my $grp = $e->retrieve_permission_grp_tree($usr->profile);
- return 0 unless $U->is_true($grp->mfa_allowed);
+ my $grp_id_list = [$usr->profile];
+ if (secondary()) {
+ push @$grp_id_list, map {$_->grp} @{$e->search_permission_usr_grp_map({usr => $usr->id})};
+ }
+
+ my $grps = $e->search_permission_grp_tree({id => $grp_id_list});
+ return 0 unless grep { $U->is_true($_->mfa_allowed) } @$grps;
# check exception list, return 0 if excepted
return 0 if user_has_exceptions($usr->id);
@@ -485,7 +501,7 @@ sub proceed_for_token {
# The difference between "required" and "allowed" modes is the recent-activity check,
# which only matters to "required" mode. If they have recent MFA activity recorded, it
# is not required.
- if ($U->is_true($grp->mfa_required) and $self->api_name =~ /required/) {
+ if ( $self->api_name =~ /required/ and grep { $U->is_true($_->mfa_required) } @$grps) {
# check recent mfa user activity, return 0 if activity age < interval
# IOW, it's not required /right this moment/.
@@ -508,7 +524,7 @@ sub proceed_for_token {
return 0 if ($usr_activity and scalar(@$usr_activity));
}
- return $U->is_true($grp->mfa_required) if ($self->api_name =~ /required/);
+ return scalar(grep { $U->is_true($_->mfa_required) } @$grps) if ($self->api_name =~ /required/);
# no activity, so MFA is both allowed and required
return 1;
-----------------------------------------------------------------------
Summary of changes:
Open-ILS/examples/opensrf.xml.example | 2 ++
.../lib/OpenILS/Application/MultiFactorAuth.pm | 28 +++++++++++++++++-----
2 files changed, 24 insertions(+), 6 deletions(-)
hooks/post-receive
--
Evergreen ILS
1
0