diff --git a/annif/openapi/annif.yaml b/annif/openapi/annif.yaml index c5143313d..531b0657d 100644 --- a/annif/openapi/annif.yaml +++ b/annif/openapi/annif.yaml @@ -180,6 +180,188 @@ paths: "503": $ref: '#/components/responses/ServiceUnavailable' x-codegen-request-body-name: documents + /projects/{project_id}/reconcile: + get: + tags: + - Reconciliation + summary: get reconciliation service manifest or reconcile against a project + operationId: annif.rest.reconcile_metadata + parameters: + - $ref: '#/components/parameters/project_id' + - in: query + description: A call to the reconciliation service API + name: queries + required: false + schema: + type: string + additionalProperties: + type: object + required: + - query + properties: + query: + type: string + description: Query string to search for + limit: + type: integer + description: Maximum number of results to return + example: + '{ + "q0": { + "query": "example query", + "limit": 10 + }, + "q1": { + "query": "another example", + "limit": 15 + } + }' + responses: + "200": + description: successful operation + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ReconcileServiceManifest' + - $ref: '#/components/schemas/ReconciliationResult' + examples: + ReconcileServiceManifest: + summary: Reconciliation service manifest + value: + { + "defaultTypes": [ + { + "id": "default-type", + "name": "Default type" + } + ], + "identifierSpace": "", + "name": "Annif Reconciliation Service for Dummy Finnish", + "schemaSpace": "http://www.w3.org/2004/02/skos/core#Concept", + "versions": [ + "0.2" + ], + "suggest": { + "entity": { + "service_path": "/suggest/entity", + "service_url": "/v1/projects/dummy-fi/reconcile" + } + }, + "view": { + "url": "{{id}}" + } + } + ReconciliationResult: + summary: Reconciliation result + value: + { + "q0": { + "result": [ + { + "id": "example-id", + "name": "example name", + "score": 0.5, + "match": true + } + ] + }, + "q1": { + "result": [ + { + "id": "another-id", + "name": "another name", + "score": 0.5, + "match": false + } + ] + } + } + "404": + $ref: '#/components/responses/NotFound' + post: + tags: + - Reconciliation + summary: reconcile against a project + operationId: annif.rest.reconcile + parameters: + - $ref: '#components/parameters/project_id' + requestBody: + content: + application/x-www-form-urlencoded: + encoding: + queries: + contentType: application/json + schema: + type: object + required: + - queries + properties: + queries: + type: object + description: A call to the reconciliation service API + additionalProperties: + type: object + required: + - query + properties: + query: + type: string + description: Query string to search for + limit: + type: integer + description: Maximum number of results to return + example: + { + "q0": { + "query": "example query", + "limit": 10 + }, + "q1": { + "query": "another example", + "limit": 15 + } + } + required: true + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ReconciliationResult' + "404": + $ref: '#/components/responses/NotFound' + /projects/{project_id}/reconcile/suggest/entity: + get: + tags: + - Reconciliation + summary: Entity auto-complete endpoint for the reconciliation service + operationId: annif.rest.reconcile_suggest + parameters: + - $ref: '#components/parameters/project_id' + - in: query + description: string to get suggestions for + name: prefix + required: true + schema: + type: string + example: example query + - in: query + description: number of suggestions to skip + name: cursor + required: false + schema: + type: integer + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ReconcileSuggestResult' + "404": + $ref: '#/components/responses/NotFound' components: schemas: ApiInfo: @@ -314,6 +496,124 @@ components: type: string example: Vulpes vulpes description: A document with attached, known good subjects + ReconcileServiceManifest: + required: + - name + - defaultTypes + - view + - identifierSpace + - schemaSpace + - versions + type: object + properties: + name: + type: string + example: Annif Reconciliation Service + identifierSpace: + type: string + example: "" + schemaSpace: + type: string + example: "http://www.w3.org/2004/02/skos/core#Concept" + defaultTypes: + type: array + items: + type: object + required: + - id + - name + properties: + id: + type: string + example: type-id + name: + type: string + example: type name + view: + type: object + required: + - url + properties: + url: + type: string + example: "{{id}}" + versions: + type: array + items: + type: string + example: 0.2 + description: Reconciliation service information + ReconciliationResult: + type: object + additionalProperties: + type: object + required: + - result + properties: + result: + type: array + items: + type: object + required: + - id + - name + - score + - match + properties: + id: + type: string + example: example-id + name: + type: string + example: example name + score: + type: number + example: 0.5 + match: + type: boolean + example: true + example: + { + "q0": { + "result": [ + { + "id": "example-id", + "name": "example name", + "score": 0.5, + "match": true + } + ] + }, + "q1": { + "result": [ + { + "id": "another-id", + "name": "another name", + "score": 0.5, + "match": false + } + ] + } + } + ReconcileSuggestResult: + type: object + required: + - result + properties: + result: + type: array + items: + type: object + required: + - name + - id + properties: + name: + type: string + example: example name + id: + type: string + example: example-id Problem: type: object properties: diff --git a/annif/rest.py b/annif/rest.py index f848117c8..27ee3c704 100644 --- a/annif/rest.py +++ b/annif/rest.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib +import json from typing import TYPE_CHECKING, Any import connexion @@ -214,3 +215,96 @@ def learn( return server_error(err) return None, 204 + + +def _reconcile(project_id: str, query: dict[str, Any]) -> list[dict[str, Any]]: + document = [{"text": query["query"]}] + parameters = {"limit": query["limit"]} if "limit" in query else {} + result = _suggest(project_id, document, parameters) + + if _is_error(result): + return result + + results = [ + { + "id": res["uri"], + "name": res["label"], + "score": res["score"], + "match": res["label"] == query["query"], + } + for res in result[0]["results"] + ] + return results + + +def reconcile_metadata( + project_id: str, **query_parameters +) -> ConnexionResponse | dict[str, Any]: + """return service manifest or reconcile against a project and return a dict + with results formatted according to OpenAPI spec""" + + try: + project = annif.registry.get_project(project_id, min_access=Access.hidden) + except ValueError: + return project_not_found_error(project_id) + + if not query_parameters: + return { + "versions": ["0.2"], + "name": "Annif Reconciliation Service for " + project.name, + "identifierSpace": "", + "schemaSpace": "http://www.w3.org/2004/02/skos/core#Concept", + "view": {"url": "{{id}}"}, + "defaultTypes": [{"id": "default-type", "name": "Default type"}], + "suggest": { + "entity": { + "service_path": "/suggest/entity", + "service_url": connexion.request.base_url + } + }, + } + else: + queries = json.loads(query_parameters["queries"]) + results = {} + for key, query in queries.items(): + data = _reconcile(project_id, query) + if _is_error(data): + return data + results[key] = {"result": data} + + return results + + +def reconcile( + project_id: str, body: dict[str, Any] +) -> ConnexionResponse | dict[str, Any]: + """reconcile against a project and return a dict with results + formatted according to OpenAPI spec""" + + queries = body["queries"] + results = {} + for key, query in queries.items(): + data = _reconcile(project_id, query) + if _is_error(data): + return data + results[key] = {"result": data} + + return results + + +def reconcile_suggest( + project_id: str, **query_parameters +) -> ConnexionResponse | dict[str, Any]: + """suggest results for the given search term and return a dict with results + formatted according to OpenAPI spec""" + + prefix = query_parameters.get("prefix") + cursor = query_parameters.get("cursor") if query_parameters.get("cursor") else 0 + limit = cursor + 10 + + result = _suggest(project_id, [{"text": prefix}], {"limit": limit}) + if _is_error(result): + return result + + results = [{"id": res["uri"], "name": res["label"]} for res in result[0]["results"]] + return {"result": results[cursor:]} diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 26e33e4ea..b1dfa5399 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -1,5 +1,7 @@ """Unit tests for Annif REST API / OpenAPI spec""" +import json + import pytest import schemathesis from hypothesis import settings @@ -113,3 +115,63 @@ def test_openapi_learn_novocab(app_client): data = [] req = app_client.post("http://localhost:8000/v1/projects/novocab/learn", json=data) assert req.status_code == 503 + + +def test_openapi_reconcile_metadata(app_client): + req = app_client.get("http://localhost:8000/v1/projects/dummy-fi/reconcile") + assert req.status_code == 200 + assert "name" in req.get_json() + + +def test_openapi_reconcile_metadata_nonexistent(app_client): + req = app_client.get("http://localhost:8000/v1/projects/nonexistent/reconcile") + assert req.status_code == 404 + + +def test_openapi_reconcile_metadata_queries(app_client): + req = app_client.get( + 'http://localhost:8000/v1/projects/dummy-fi/reconcile?queries=\ + {"q0": {"query": "example text"}}' + ) + assert req.status_code == 200 + assert "result" in req.get_json()["q0"] + + +def test_openapi_reconcile_metadata_queries_nonexistent(app_client): + req = app_client.get( + 'http://localhost:8000/v1/projects/nonexistent/reconcile?queries=\ + {"q0": {"query": "example text"}}' + ) + assert req.status_code == 404 + + +def test_openapi_reconcile(app_client): + data = {"queries": json.dumps({"q0": {"query": "example text"}})} + req = app_client.post( + "http://localhost:8000/v1/projects/dummy-fi/reconcile", data=data + ) + assert req.status_code == 200 + assert "result" in req.get_json()["q0"] + + +def test_openapi_reconcile_nonexistent(app_client): + data = {"queries": json.dumps({"q0": {"query": "example text"}})} + req = app_client.post( + "http://localhost:8000/v1/projects/nonexistent/reconcile", data=data + ) + assert req.status_code == 404 + + +def test_openapi_reconcile_suggest(app_client): + req = app_client.get( + "http://localhost:8000/v1/projects/dummy-fi/reconcile/suggest/entity?prefix=example" + ) + assert req.status_code == 200 + assert "result" in req.get_json() + + +def test_openapi_reconcile_suggest_nonexistent(app_client): + req = app_client.get( + "http://localhost:8000/v1/projects/nonexistent/reconcile/suggest/entity?prefix=example" + ) + assert req.status_code == 404 diff --git a/tests/test_rest.py b/tests/test_rest.py index e56a24b21..51c698137 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -233,3 +233,59 @@ def test_rest_learn_not_supported(app): with app.app_context(): result = annif.rest.learn("tfidf-fi", []) assert result.status_code == 503 + + +def test_rest_reconcile_metadata(app): + with app.app_context(): + results = annif.rest.reconcile_metadata("dummy-fi") + assert results["name"] == "Annif Reconciliation Service for Dummy Finnish" + + +def test_rest_reocncile_metadata_nonexistent(app): + with app.app_context(): + result = annif.rest.reconcile_metadata("nonexistent") + assert result.status_code == 404 + + +def test_rest_reconcile_metadata_queries(app): + with app.app_context(): + results = annif.rest.reconcile_metadata( + "dummy-fi", queries='{"q0": {"query": "example text"}}' + ) + assert "result" in results["q0"] + + +def test_rest_reconcile_metadata_queries_nonexistent(app): + with app.app_context(): + result = annif.rest.reconcile_metadata( + "nonexistent", queries='{"q0": {"query": "example text"}}' + ) + assert result.status_code == 404 + + +def test_rest_reconcile(app): + with app.app_context(): + results = annif.rest.reconcile( + "dummy-fi", {"queries": {"q0": {"query": "example text"}}} + ) + assert "result" in results["q0"] + + +def test_rest_reconcile_nonexistent(app): + with app.app_context(): + result = annif.rest.reconcile( + "nonexistent", {"queries": {"q0": {"query": "example text"}}} + ) + assert result.status_code == 404 + + +def test_rest_reconcile_suggest(app): + with app.app_context(): + results = annif.rest.reconcile_suggest("dummy-fi", prefix="example text") + assert "result" in results + + +def test_rest_reconcile_nonexistent(app): + with app.app_context(): + result = annif.rest.reconcile_suggest("nonexistent", prefix="example text") + assert result.status_code == 404