fix: complete search API contract
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
@@ -17,6 +18,7 @@ API_VERSION = "v1"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
MAX_RESULT_WINDOW = 10_000
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
@@ -54,6 +56,26 @@ def date(value: str | None, name: str) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
def catalog_code(name: str, labels: tuple[str | None, str | None]) -> str:
|
||||
value = json.dumps(labels, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
return f"{name}:v1:{base64.urlsafe_b64encode(value).decode().rstrip('=')}"
|
||||
|
||||
|
||||
def catalog_label(name: str, code: str, language: str) -> str:
|
||||
prefix = f"{name}:v1:"
|
||||
if not code.startswith(prefix):
|
||||
raise ApiError(400, f"{name} must be a catalog code")
|
||||
try:
|
||||
encoded = code[len(prefix):]
|
||||
values = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
|
||||
value = values[0 if language == "ru" else 1]
|
||||
except (IndexError, TypeError, ValueError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ApiError(400, f"{name} must be a catalog code") from error
|
||||
if not isinstance(values, list) or len(values) != 2 or not isinstance(value, str):
|
||||
raise ApiError(400, f"{name} must be a catalog code")
|
||||
return value
|
||||
|
||||
|
||||
def openapi() -> dict:
|
||||
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
|
||||
return {
|
||||
@@ -73,9 +95,9 @@ def openapi() -> dict:
|
||||
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
|
||||
]}},
|
||||
"/search/filters": {"get": {"responses": responses}},
|
||||
"/documents/{code}": {"get": {"responses": responses}},
|
||||
"/documents/{code}/editions": {"get": {"responses": responses}},
|
||||
"/documents/{code}/editions/{edition}": {"get": {"responses": responses}},
|
||||
"/documents/{code}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
|
||||
"/documents/{code}/editions": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
|
||||
"/documents/{code}/editions/{edition}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}, {"name": "edition", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,6 +128,8 @@ class Api:
|
||||
raise ApiError(400, "language must be ru or ky")
|
||||
page = parse_positive(one(query, "page"), "page", 1, 1_000_000)
|
||||
page_size = parse_positive(one(query, "page_size"), "page_size", 20, MAX_PAGE_SIZE)
|
||||
if page * page_size >= MAX_RESULT_WINDOW:
|
||||
raise ApiError(400, f"page and page_size must stay within {MAX_RESULT_WINDOW} results")
|
||||
sort = one(query, "sort") or "relevance"
|
||||
if sort not in {"relevance", "date"}:
|
||||
raise ApiError(400, "sort must be relevance or date")
|
||||
@@ -114,7 +138,7 @@ class Api:
|
||||
for parameter, field in fields.items():
|
||||
value = one(query, parameter)
|
||||
if value:
|
||||
filters.append({"term": {field: value}})
|
||||
filters.append({"term": {field: catalog_label(parameter, value, language)}})
|
||||
date_from, date_to = date(one(query, "date_from"), "date_from"), date(one(query, "date_to"), "date_to")
|
||||
if date_from and date_to and date_from > date_to:
|
||||
raise ApiError(400, "date_from must not be later than date_to")
|
||||
@@ -150,14 +174,14 @@ class Api:
|
||||
language = one(query, "language") or "ru"
|
||||
if language not in LANGUAGES:
|
||||
raise ApiError(400, "language must be ru or ky")
|
||||
fields = {"document_types": ("document_type_ru", "document_type_ky"), "statuses": ("status_ru", "status_ky"), "authorities": ("authority_paths_ru", "authority_paths_ky")}
|
||||
body = {"size": 0, "aggs": {name: {"multi_terms": {"terms": [{"field": field} for field in pair], "size": 1000}, "aggs": {"documents": {"cardinality": {"field": "document_code", "precision_threshold": 40000}}}} for name, pair in fields.items()}}
|
||||
fields = {"document_types": ("document_type", "document_type_ru", "document_type_ky"), "statuses": ("status", "status_ru", "status_ky"), "authorities": ("authority", "authority_paths_ru", "authority_paths_ky")}
|
||||
body = {"size": 0, "query": {"term": {"is_current_edition": True}}, "aggs": {name: {"multi_terms": {"terms": [{"field": field} for field in pair[1:]], "size": 1000}, "aggs": {"documents": {"cardinality": {"field": "document_code", "precision_threshold": 40000}}}} for name, pair in fields.items()}}
|
||||
response = self.query_opensearch(body)
|
||||
try:
|
||||
aggregations = response["aggregations"]
|
||||
values = {
|
||||
name: [{"code": item["key"][0 if language == "ru" else 1], "labels": {"ru": item["key"][0], "ky": item["key"][1]}, "count": item["documents"]["value"]} for item in aggregations[name]["buckets"]]
|
||||
for name in fields
|
||||
name: [{"code": catalog_code(pair[0], (item["key"][0], item["key"][1])), "labels": {"ru": item["key"][0], "ky": item["key"][1]}, "count": item["documents"]["value"]} for item in aggregations[name]["buckets"]]
|
||||
for name, pair in fields.items()
|
||||
}
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ApiError(502, "search backend returned incomplete filters") from error
|
||||
|
||||
Reference in New Issue
Block a user