From e5d38fd577845148832d44c053a75e947570292c Mon Sep 17 00:00:00 2001 From: bkoschicek Date: Wed, 18 Oct 2023 15:42:51 +0200 Subject: [PATCH 1/5] new endpoint established --- openatlas/api/endpoints/content.py | 23 ++++++++++++++++++++++- openatlas/api/routes.py | 6 +++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/openatlas/api/endpoints/content.py b/openatlas/api/endpoints/content.py index 68d383f85..913c96565 100644 --- a/openatlas/api/endpoints/content.py +++ b/openatlas/api/endpoints/content.py @@ -1,6 +1,6 @@ from typing import Union -from flask import Response, g +from flask import Response, g, jsonify from flask_restful import Resource, marshal from openatlas import app @@ -28,6 +28,27 @@ def get() -> Union[tuple[Resource, int], Response]: return marshal(content, content_template()), 200 +class GetBackendDetails(Resource): + @staticmethod + def get() -> Union[tuple[Resource, int], Response]: + parser = language.parse_args() + lang = parser['lang'] + content = { + 'version': app.config['VERSION'], + 'siteName': get_translation('site_name_for_frontend', lang), + 'imageProcessing': g.settings['image_processing'], + 'imageSizes': app.config['IMAGE_SIZE'], + 'IIIF': { + 'enabled': app.config['IIIF']['enabled'], + 'url': app.config['IIIF']['url'], + 'version': app.config['IIIF']['version']} + } + if parser['download']: + download(content, content_template(), 'content') + # return marshal(content, content_template()), 200 + return content + + class ClassMapping(Resource): @staticmethod def get() -> Union[tuple[Resource, int], Response]: diff --git a/openatlas/api/routes.py b/openatlas/api/routes.py index f9afe15b6..ffd835c3a 100644 --- a/openatlas/api/routes.py +++ b/openatlas/api/routes.py @@ -3,7 +3,7 @@ from openatlas.api.endpoints.iiif import \ (IIIFManifest, IIIFImageV2, IIIFCanvasV2, IIIFSequenceV2) from openatlas.api.endpoints.content import ClassMapping, \ - GetContent, SystemClassCount + GetContent, SystemClassCount, GetBackendDetails from openatlas.api.endpoints.special import GetGeometricEntities, \ ExportDatabase, GetSubunits from openatlas.api.endpoints.display_image import \ @@ -74,6 +74,10 @@ def add_routes_v03(api: Api) -> None: GetContent, '/content/', endpoint="content") + api.add_resource( + GetBackendDetails, + '/backend_details/', + endpoint="backend_detail") api.add_resource( ClassMapping, '/classes/', From d5c1fdcffe6ddeba4e3b66281f1a2405089310fd Mon Sep 17 00:00:00 2001 From: bkoschicek Date: Mon, 23 Oct 2023 14:16:08 +0200 Subject: [PATCH 2/5] added url in api errors --- openatlas/views/error.py | 26 +++++++++++++++++++++++++- sphinx/source/technical/api.rst | 5 +++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/openatlas/views/error.py b/openatlas/views/error.py index 2b737c0f7..651eabe98 100644 --- a/openatlas/views/error.py +++ b/openatlas/views/error.py @@ -33,7 +33,11 @@ def forbidden(e: Exception) -> tuple[str, int]: def page_not_found(e: Exception) -> tuple[Any, int]: if request.path.startswith('/api/'): return jsonify({ - 'message': 'Endpoint not found', + 'title': 'Endpoint not found', + 'message': + 'The requestes endpoint does not exist. ' + 'Please consult the manual or the Swagger documentation: ' + f'{request.url_root }swagger', 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 404}), 404 @@ -65,6 +69,7 @@ def access_denied(_e: Exception) -> tuple[Any, int]: 'title': 'Access denied', 'message': 'You do not have access to the API. ' 'Please ask the data provider for permission.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 403}), 403 @@ -74,6 +79,7 @@ def file_not_found(_e: Exception) -> tuple[Any, int]: return jsonify({ 'title': 'File not found', 'message': 'No file was found for the requested ID.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 404}), 404 @@ -83,6 +89,7 @@ def entity_does_not_exist(_e: Exception) -> tuple[Any, int]: return jsonify({ 'title': 'Entity does not exist', 'message': 'The requested entity does not exist in the database.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 404}), 404 @@ -94,6 +101,7 @@ def invalid_cidoc_class_code(_e: Exception) -> tuple[Any, int]: 'message': 'The CIDOC class value is invalid, use "all" or ' + str(list(g.cidoc_classes)), + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -104,6 +112,7 @@ def invalid_limit(_e: Exception) -> tuple[Any, int]: 'title': 'Invalid limit value', 'message': 'Only integers between 1 and 100 are allowed for the limit.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -115,6 +124,7 @@ def invalid_search_syntax(_e: Exception) -> tuple[Any, int]: 'message': 'The search request contains major errors. ' 'Please confer the manual.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -126,6 +136,7 @@ def invalid_system_class(_e: Exception) -> tuple[Any, int]: 'message': 'The system_classes value is invalid, use "all" or ' + str(list(g.classes)), + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -137,6 +148,7 @@ def invalid_view_class(_e: Exception) -> tuple[Any, int]: 'message': 'The view_classes value is invalid, use "all" or ' + str(list(g.view_class_mapping)), + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -147,6 +159,7 @@ def last_entity_error(_e: Exception) -> tuple[Any, int]: 'title': 'ID is last entity', 'message': 'The requested ID is the last entity, please choose another ID.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -158,6 +171,7 @@ def invalid_logical_operator(_e: Exception) -> tuple[Any, int]: 'message': 'The logical operator is invalid. Please use: ' f'{app.config["LOGICAL_OPERATOR"]}', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -167,6 +181,7 @@ def no_entity_available(_e: Exception) -> tuple[Any, int]: return jsonify({ 'title': 'No entity available', 'message': 'No entity exist for this request.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 404}), 404 @@ -177,6 +192,7 @@ def no_license(_e: Exception) -> tuple[Any, int]: 'title': 'No license', 'message': 'The requested file has no license and cannot be displayed.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 409}), 409 @@ -186,6 +202,7 @@ def no_search_string(_e: Exception) -> tuple[Any, int]: return jsonify({ 'title': 'No search values', 'message': 'Search values are empty.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -195,6 +212,7 @@ def not_a_type(_e: Exception) -> tuple[Any, int]: return jsonify({ 'title': 'Entity is not a type', 'message': 'Requested ID either does not exist or is not a Type.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -204,6 +222,7 @@ def not_a_place(_e: Exception) -> tuple[Any, int]: return jsonify({ 'title': 'ID is not a valid place', 'message': 'This endpoint requires a valid ID of a place entity.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -215,6 +234,7 @@ def invalid_operator(_e: Exception) -> tuple[Any, int]: 'message': 'The compare operator is invalid. ' f'Please use: {app.config["COMPARE_OPERATORS"]}', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -227,6 +247,7 @@ def empty_query(_e: Exception) -> tuple[Any, int]: 'The /query endpoint requires at least one of the following ' 'parameters: entities, cidoc_classes, view_classes, ' 'system_classes.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -238,6 +259,7 @@ def invalid_search_category(_e: Exception) -> tuple[Any, int]: 'message': 'The search category is invalid. Please use: ' f'{app.config["VALID_CATEGORIES"]}', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -248,6 +270,7 @@ def one_id_is_not_a_type(_e: Exception) -> tuple[Any, int]: 'title': 'One entity ID is not a type', 'message': 'One of the requested ID either does not exist or is not a Type.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 @@ -258,5 +281,6 @@ def value_not_an_integer(_e: Exception) -> tuple[Any, int]: 'title': 'Invalid search value', 'message': 'The search values need to be an integer for the chosen category.', + 'url': request.url, 'timestamp': datetime.datetime.now(), 'status': 400}), 400 diff --git a/sphinx/source/technical/api.rst b/sphinx/source/technical/api.rst index 31ff63d97..fe5e6a877 100644 --- a/sphinx/source/technical/api.rst +++ b/sphinx/source/technical/api.rst @@ -161,8 +161,9 @@ Example: .. code-block:: { - "title": "entity does not exist", - "message": "Requested entity does not exist. Try another ID" + "title": "entity does not exist", + "message": "Requested entity does not exist. Try another ID", + "url": "https://demo.openatlas.eu/api/entity/9999/, "timestamp": "Tue, 19 Jul 2022 13:59:13 GMT", "status": 404 } From d3da02b1e1292a7da9942d2e82b9a7adae573c68 Mon Sep 17 00:00:00 2001 From: bkoschicek Date: Mon, 23 Oct 2023 17:18:06 +0200 Subject: [PATCH 3/5] implement endpoint with tests --- config/api.py | 1 + openatlas/api/endpoints/content.py | 30 +++++++++++++++------------- openatlas/api/resources/templates.py | 16 +++++++++++++++ tests/test_api.py | 3 +++ 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/config/api.py b/config/api.py index cb8fe3c73..19fb8547c 100644 --- a/config/api.py +++ b/config/api.py @@ -41,3 +41,4 @@ # Used to connect to password protected Vocabs systems VOCABS_PASS = '' +API_VERSIONS = ['0.3'] diff --git a/openatlas/api/endpoints/content.py b/openatlas/api/endpoints/content.py index 913c96565..8efd9cf6e 100644 --- a/openatlas/api/endpoints/content.py +++ b/openatlas/api/endpoints/content.py @@ -1,14 +1,15 @@ from typing import Union -from flask import Response, g, jsonify +from flask import Response, g from flask_restful import Resource, marshal from openatlas import app from openatlas.api.resources.model_mapper import get_overview_counts -from openatlas.api.resources.parser import language +from openatlas.api.resources.parser import language, default from openatlas.api.resources.resolve_endpoints import download from openatlas.api.resources.templates import ( - class_overview_template, content_template, overview_template) + class_overview_template, content_template, overview_template, + backend_details_template) from openatlas.models.content import get_translation @@ -31,22 +32,23 @@ def get() -> Union[tuple[Resource, int], Response]: class GetBackendDetails(Resource): @staticmethod def get() -> Union[tuple[Resource, int], Response]: - parser = language.parse_args() - lang = parser['lang'] - content = { + parser = default.parse_args() + details = { 'version': app.config['VERSION'], - 'siteName': get_translation('site_name_for_frontend', lang), - 'imageProcessing': g.settings['image_processing'], - 'imageSizes': app.config['IMAGE_SIZE'], + 'apiVersions': app.config['API_VERSIONS'], + 'siteName': g.settings['site_name'], + 'imageProcessing': { + 'enabled': g.settings['image_processing'], + 'availableImageSizes': + app.config['IMAGE_SIZE'] + if g.settings['image_processing'] else None}, 'IIIF': { 'enabled': app.config['IIIF']['enabled'], 'url': app.config['IIIF']['url'], - 'version': app.config['IIIF']['version']} - } + 'version': app.config['IIIF']['version']}} if parser['download']: - download(content, content_template(), 'content') - # return marshal(content, content_template()), 200 - return content + download(details, backend_details_template(), 'content') + return marshal(details, backend_details_template()), 200 class ClassMapping(Resource): diff --git a/openatlas/api/resources/templates.py b/openatlas/api/resources/templates.py index ec47da766..3db02a89e 100644 --- a/openatlas/api/resources/templates.py +++ b/openatlas/api/resources/templates.py @@ -308,6 +308,22 @@ def content_template() -> dict[str, Type[String]]: 'imageSizes': fields.Raw} +def backend_details_template() -> dict[str, Type[String]]: + image_processing = { + 'enabled': fields.String, + 'availableImageSizes': fields.String} + iiif = { + 'enabled': fields.String, + 'url': fields.String, + 'version': fields.String} + return { + 'version': fields.String, + 'apiVersions': fields.Raw, + 'siteName': fields.String, + 'imageProcessing': fields.Nested(image_processing), + 'IIIF': fields.Nested(iiif)} + + def licensed_file_template(entities: list[Entity]) -> dict[str, Any]: file = { 'display': fields.String, diff --git a/tests/test_api.py b/tests/test_api.py index 48a913249..ec9febbd8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -84,6 +84,9 @@ def test_api(self) -> None: rv = self.app.get(url_for('api_03.content')).get_json() assert bool(rv['intro'] == 'This is English') + rv = self.app.get(url_for('api_03.backend_detail')).get_json() + assert bool(rv['version'] == app.config['VERSION']) + rv = self.app.get(url_for('api_03.system_class_count')).get_json() assert bool(rv['person']) From f8e334522dbfbdc3c27606b5c6bcdda6ba4d668e Mon Sep 17 00:00:00 2001 From: bkoschicek Date: Mon, 23 Oct 2023 18:12:25 +0200 Subject: [PATCH 4/5] coverage --- tests/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index ec9febbd8..2b8698606 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -86,6 +86,9 @@ def test_api(self) -> None: rv = self.app.get(url_for('api_03.backend_detail')).get_json() assert bool(rv['version'] == app.config['VERSION']) + rv = self.app.get( + url_for('api_03.backend_detail', download=True)).get_json() + assert bool(rv['version'] == app.config['VERSION']) rv = self.app.get(url_for('api_03.system_class_count')).get_json() assert bool(rv['person']) From e879fd58d21d790b7ca6bf7d0c8f833822a57853 Mon Sep 17 00:00:00 2001 From: bkoschicek Date: Tue, 24 Oct 2023 11:00:25 +0200 Subject: [PATCH 5/5] added to swagger --- openatlas/api/resources/templates.py | 2 +- openatlas/api/routes.py | 2 +- openatlas/api/swagger.json | 76 ++++++++++++++++++++++++++++ tests/test_api.py | 4 +- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/openatlas/api/resources/templates.py b/openatlas/api/resources/templates.py index 3db02a89e..1875af7be 100644 --- a/openatlas/api/resources/templates.py +++ b/openatlas/api/resources/templates.py @@ -311,7 +311,7 @@ def content_template() -> dict[str, Type[String]]: def backend_details_template() -> dict[str, Type[String]]: image_processing = { 'enabled': fields.String, - 'availableImageSizes': fields.String} + 'availableImageSizes': fields.Raw} iiif = { 'enabled': fields.String, 'url': fields.String, diff --git a/openatlas/api/routes.py b/openatlas/api/routes.py index ffd835c3a..0bb0ad65c 100644 --- a/openatlas/api/routes.py +++ b/openatlas/api/routes.py @@ -77,7 +77,7 @@ def add_routes_v03(api: Api) -> None: api.add_resource( GetBackendDetails, '/backend_details/', - endpoint="backend_detail") + endpoint="backend_details") api.add_resource( ClassMapping, '/classes/', diff --git a/openatlas/api/swagger.json b/openatlas/api/swagger.json index 6b88f92f4..38d217816 100644 --- a/openatlas/api/swagger.json +++ b/openatlas/api/swagger.json @@ -850,6 +850,35 @@ } } }, + "/backend_details/": { + "get": { + "tags": [ + "Administrative Endpoints" + ], + "description": "Retrieves a list of information of the backend configuration.", + "operationId": "GetBackendDetails", + "parameters": [ + { + "$ref": "#/components/parameters/download" + } + ], + "responses": { + "200": { + "description": "OpenAtlas backend details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendDetailsModel" + } + } + } + }, + "404": { + "description": "Something went wrong. Please consult the error message." + } + } + } + }, "/system_class_count/": { "get": { "tags": [ @@ -2357,6 +2386,53 @@ } } }, + "BackendDetailsModel": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "apiVersions": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "imageProcessing": { + "type": "object", + "properties": { + "enabled": { + "type": "string" + }, + "availableImageSizes": { + "type": "object", + "properties": { + "thumbnail": { + "type": "string" + }, + "table": { + "type": "string" + } + } + } + } + }, + "IIIF": { + "type": "object", + "properties": { + "enabled": { + "type": "string" + }, + "url": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + } + }, "SystemClassCountModel": { "type": "object", "properties": { diff --git a/tests/test_api.py b/tests/test_api.py index 2b8698606..3df11952c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -84,10 +84,10 @@ def test_api(self) -> None: rv = self.app.get(url_for('api_03.content')).get_json() assert bool(rv['intro'] == 'This is English') - rv = self.app.get(url_for('api_03.backend_detail')).get_json() + rv = self.app.get(url_for('api_03.backend_details')).get_json() assert bool(rv['version'] == app.config['VERSION']) rv = self.app.get( - url_for('api_03.backend_detail', download=True)).get_json() + url_for('api_03.backend_details', download=True)).get_json() assert bool(rv['version'] == app.config['VERSION']) rv = self.app.get(url_for('api_03.system_class_count')).get_json()