diff --git a/trove/exceptions.py b/trove/exceptions.py index d71e85f3b..6f68b0f20 100644 --- a/trove/exceptions.py +++ b/trove/exceptions.py @@ -1,5 +1,15 @@ +import http +import inspect + + class TroveError(Exception): - pass + # set more helpful codes in subclasses + http_status: int = http.HTTPStatus.INTERNAL_SERVER_ERROR + error_location: str = '' + + def __init__(self, *args): + super().__init__(*args) + self.error_location = _get_nearest_code_location() ### @@ -13,11 +23,15 @@ class CannotDigestMediatype(DigestiveError): pass +class CannotDigestDateValue(DigestiveError): + pass + + ### # parsing a request class RequestParsingError(TroveError): - pass + http_status = http.HTTPStatus.BAD_REQUEST class InvalidQuotedIri(RequestParsingError): @@ -28,10 +42,41 @@ class InvalidQueryParamName(RequestParsingError): pass -class InvalidDate(RequestParsingError): +class InvalidFilterOperator(InvalidQueryParamName): + pass + + +class InvalidQueryParamValue(RequestParsingError): + pass + + +class InvalidSearchText(InvalidQueryParamValue): + pass + + +class MissingRequiredQueryParam(RequestParsingError): + pass + + +class InvalidRepeatedQueryParam(RequestParsingError): + pass + + +class InvalidPropertyPath(RequestParsingError): + pass + + +### +# rendering a response + +class ResponseRenderingError(TroveError): pass +class CannotRenderMediatype(ResponseRenderingError): + http_status = http.HTTPStatus.NOT_ACCEPTABLE + + ### # primitive rdf @@ -72,4 +117,14 @@ class OwlObjection(PrimitiveRdfWhoopsy): ### -# rendering a response +# local helpers + +def _get_nearest_code_location() -> str: + try: + _raise_frame = next( + _frameinfo for _frameinfo in inspect.stack() + if _frameinfo.filename != __file__ # nearest frame not in this file + ) + return f'{_raise_frame.filename}::{_raise_frame.lineno}' + except Exception: + return 'unknown' # eh, whatever diff --git a/trove/extract/legacy_sharev2.py b/trove/extract/legacy_sharev2.py index b45c83ddb..6add0221a 100644 --- a/trove/extract/legacy_sharev2.py +++ b/trove/extract/legacy_sharev2.py @@ -189,7 +189,7 @@ def _date_or_none(maybe_date) -> typing.Optional[datetime.date]: return maybe_date if maybe_date is None: return None - raise trove_exceptions.InvalidDate(maybe_date) + raise trove_exceptions.CannotDigestDateValue(maybe_date) def _focustype_iris(mnode: MutableNode) -> typing.Iterable[str]: diff --git a/trove/models/indexcard.py b/trove/models/indexcard.py index 39b4d9e6e..b7a411ffa 100644 --- a/trove/models/indexcard.py +++ b/trove/models/indexcard.py @@ -307,5 +307,5 @@ def deriver_cls(self): def as_rdf_literal(self) -> rdf.Literal: return rdf.literal( self.derived_text, - self.deriver_cls.derived_datatype_iris(), + datatype_iris=self.deriver_cls.derived_datatype_iris(), ) diff --git a/trove/render/__init__.py b/trove/render/__init__.py index 741b808cb..637d948b1 100644 --- a/trove/render/__init__.py +++ b/trove/render/__init__.py @@ -3,6 +3,7 @@ from trove import exceptions as trove_exceptions from trove.vocab.trove import TROVE_API_THESAURUS from trove.vocab.namespaces import NAMESPACES_SHORTHAND +from ._base import BaseRenderer from .jsonapi import RdfJsonapiRenderer from .html_browse import RdfHtmlBrowseRenderer from .turtle import RdfTurtleRenderer @@ -10,15 +11,19 @@ from .simple_json import TrovesearchSimpleJsonRenderer +__all__ = ('get_renderer',) + +RENDERERS: tuple[type[BaseRenderer], ...] = ( + RdfHtmlBrowseRenderer, + RdfJsonapiRenderer, + RdfTurtleRenderer, + RdfJsonldRenderer, + TrovesearchSimpleJsonRenderer, +) + RENDERER_BY_MEDIATYPE = { _renderer_cls.MEDIATYPE: _renderer_cls - for _renderer_cls in ( - RdfHtmlBrowseRenderer, - RdfJsonapiRenderer, - RdfTurtleRenderer, - RdfJsonldRenderer, - TrovesearchSimpleJsonRenderer, - ) + for _renderer_cls in RENDERERS } DEFAULT_RENDERER = RdfJsonapiRenderer # the most stable one diff --git a/trove/render/_base.py b/trove/render/_base.py index 29385d7fe..2110c511b 100644 --- a/trove/render/_base.py +++ b/trove/render/_base.py @@ -1,12 +1,21 @@ import abc +import json from typing import Optional, ClassVar from django import http from primitive_metadata import primitive_rdf as rdf +from trove import exceptions as trove_exceptions +from trove.vocab import mediatypes + class BaseRenderer(abc.ABC): - MEDIATYPE: ClassVar[str] # required in subclasses + # required in subclasses + MEDIATYPE: ClassVar[str] + # should be set when render_error_document is overridden: + ERROR_MEDIATYPE: ClassVar[str] = mediatypes.JSONAPI + # should be set when the renderer expects a specific derived metadata format + INDEXCARD_DERIVER_IRI: ClassVar[str | None] = None def __init__( self, *, @@ -30,6 +39,25 @@ def render_response( **response_kwargs, ) + def render_error_response(self, error: trove_exceptions.TroveError): + return http.HttpResponse( + content=self.render_error_document(error), + content_type=self.ERROR_MEDIATYPE, + status=error.http_status, + ) + @abc.abstractmethod def render_document(self, data: rdf.RdfGraph, focus_iri: str) -> str: raise NotImplementedError + + def render_error_document(self, error: trove_exceptions.TroveError) -> str: + # may override, but default to jsonapi + return json.dumps( + {'errors': [{ # https://jsonapi.org/format/#error-objects + 'status': error.http_status, + 'code': error.error_location, + 'title': error.__class__.__name__, + 'detail': str(error), + }]}, + indent=2, + ) diff --git a/trove/render/jsonapi.py b/trove/render/jsonapi.py index cd1ae83bd..3f8e3f40a 100644 --- a/trove/render/jsonapi.py +++ b/trove/render/jsonapi.py @@ -51,6 +51,7 @@ class RdfJsonapiRenderer(BaseRenderer): note: does not support relationship links (or many other jsonapi features) ''' MEDIATYPE = mediatypes.JSONAPI + INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] __to_include: set[primitive_rdf.RdfObject] | None = None diff --git a/trove/render/jsonld.py b/trove/render/jsonld.py index a7bb8b14d..58ad7a2c8 100644 --- a/trove/render/jsonld.py +++ b/trove/render/jsonld.py @@ -5,7 +5,7 @@ from primitive_metadata import primitive_rdf as rdf from trove import exceptions as trove_exceptions -from trove.vocab.namespaces import RDF, OWL +from trove.vocab.namespaces import RDF, OWL, TROVE from trove.vocab import mediatypes from ._base import BaseRenderer @@ -18,6 +18,7 @@ class RdfJsonldRenderer(BaseRenderer): MEDIATYPE = mediatypes.JSONLD + INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] __visiting_iris: set | None = None diff --git a/trove/render/simple_json.py b/trove/render/simple_json.py index 7dfa2565d..68f16362c 100644 --- a/trove/render/simple_json.py +++ b/trove/render/simple_json.py @@ -16,6 +16,7 @@ class TrovesearchSimpleJsonRenderer(BaseRenderer): '''for "simple json" search api -- very entangled with trove/trovesearch/trovesearch_gathering.py ''' MEDIATYPE = mediatypes.JSON + INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] def render_document(self, data: rdf.RdfGraph, focus_iri: str) -> str: _focustypes = set(data.q(focus_iri, RDF.type)) diff --git a/trove/render/turtle.py b/trove/render/turtle.py index a488d9ec5..c035e773a 100644 --- a/trove/render/turtle.py +++ b/trove/render/turtle.py @@ -1,10 +1,12 @@ from primitive_metadata import primitive_rdf as rdf +from trove.vocab.namespaces import TROVE from ._base import BaseRenderer class RdfTurtleRenderer(BaseRenderer): MEDIATYPE = 'text/turtle' + INDEXCARD_DERIVER_IRI = TROVE['derive/osfmap_json'] def render_document(self, rdf_graph: rdf.RdfGraph, focus_iri: str): return rdf.turtle_from_tripledict(rdf_graph.tripledict, focus=focus_iri) diff --git a/trove/trovesearch/search_params.py b/trove/trovesearch/search_params.py index 121a909ec..14d3a6673 100644 --- a/trove/trovesearch/search_params.py +++ b/trove/trovesearch/search_params.py @@ -98,7 +98,7 @@ def _gather_shorthand(cls, queryparams: QueryparamDict): try: (_shortname,) = _qp_name.bracketed_names except ValueError: - raise # TODO: 400 response + raise trove_exceptions.InvalidQueryParamName(_qp_name) else: _prefixmap[_shortname] = _iri return NAMESPACES_SHORTHAND.with_update(_prefixmap) @@ -119,7 +119,7 @@ class Textsegment: def __post_init__(self): if self.is_negated and self.is_fuzzy: - raise ValueError(f'{self}: cannot have both is_negated and is_fuzzy') + raise trove_exceptions.InvalidSearchText(self.text, "search cannot be both negated and fuzzy") def words(self): return self.text.split() @@ -310,7 +310,10 @@ def from_filter_param(cls, param_name: QueryparamName, param_value: str): try: _operator = SearchFilter.FilterOperator.from_shortname(_operator_value) except ValueError: - raise ValueError(f'unrecognized search-filter operator "{_operator_value}"') + raise trove_exceptions.InvalidQueryParamName( + str(param_name), + f'unknown filter operator "{_operator_value}"', + ) _propertypath_set = _parse_propertypath_set(_serialized_path_set) _is_date_filter = all( is_date_property(_path[-1]) @@ -323,7 +326,10 @@ def from_filter_param(cls, param_name: QueryparamName, param_value: str): else SearchFilter.FilterOperator.ANY_OF ) if _operator.is_date_operator() and not _is_date_filter: - raise ValueError(f'cannot use date operator {_operator.value} on non-date property') + raise trove_exceptions.InvalidQueryParamName( + str(param_name), + f'cannot use date operator "{_operator.to_shortname()}" on non-date property' + ) _value_list = [] if not _operator.is_valueless_operator(): for _value in split_queryparam_value(param_value): @@ -390,7 +396,7 @@ def _from_sort_param_str(cls, param_value: str) -> typing.Iterable['SortParam']: _sort_property = _sort.lstrip(DESCENDING_SORT_PREFIX) _property_iri = osfmap_shorthand().expand_iri(_sort_property) if not is_date_property(_property_iri): - raise ValueError(f'bad sort: {_sort_property}') # TODO: nice response + raise trove_exceptions.InvalidQueryParamValue('sort', _sort_property, "may not sort on non-date properties") yield cls( property_iri=_property_iri, descending=param_value.startswith(DESCENDING_SORT_PREFIX), @@ -476,7 +482,7 @@ class ValuesearchParams(CardsearchParams): def parse_queryparams(cls, queryparams: QueryparamDict) -> dict: _raw_propertypath = _get_single_value(queryparams, QueryparamName('valueSearchPropertyPath')) if not _raw_propertypath: - raise ValueError('TODO: 400 valueSearchPropertyPath required') + raise trove_exceptions.MissingRequiredQueryParam('valueSearchPropertyPath') return { **super().parse_queryparams(queryparams), 'valuesearch_propertypath_set': _parse_propertypath_set(_raw_propertypath, allow_globs=False), @@ -554,7 +560,7 @@ def _get_single_value( try: (_singlevalue,) = _paramvalues except ValueError: - raise ValueError(f'expected at most one {queryparam_name} value, got {len(_paramvalues)}') + raise trove_exceptions.InvalidRepeatedQueryParam(str(queryparam_name)) else: return _singlevalue @@ -574,9 +580,12 @@ def _parse_propertypath(serialized_path: str, *, allow_globs=True) -> tuple[str, ) if GLOB_PATHSTEP in _path: if not allow_globs: - raise ValueError(f'no * allowed (got {serialized_path})') + raise trove_exceptions.InvalidPropertyPath(serialized_path, 'no * allowed') if any(_pathstep != GLOB_PATHSTEP for _pathstep in _path): - raise ValueError(f'path must be all * or no * (got {serialized_path})') + raise trove_exceptions.InvalidPropertyPath( + serialized_path, + f'path must be all * or no * (got {serialized_path})', + ) return _path diff --git a/trove/trovesearch/trovesearch_gathering.py b/trove/trovesearch/trovesearch_gathering.py index ad3fe52cf..31bf85d48 100644 --- a/trove/trovesearch/trovesearch_gathering.py +++ b/trove/trovesearch/trovesearch_gathering.py @@ -1,5 +1,4 @@ import dataclasses -import enum import logging import urllib.parse @@ -29,7 +28,6 @@ JSONAPI_LINK_OBJECT, JSONAPI_MEMBERNAME, ) -from trove.vocab import mediatypes from trove.vocab.osfmap import ( osfmap_shorthand, OSFMAP_THESAURUS, @@ -64,41 +62,28 @@ literal('trove search', language='en'), ), norms=TROVE_GATHERING_NORMS, - gatherer_kwargnames={'search_params', 'specific_index', 'trovesearch_flags'}, + gatherer_kwargnames={'search_params', 'specific_index', 'deriver_iri'}, ) -class TrovesearchFlags(enum.Flag): - OSFMAP_JSON = enum.auto() - ONLY_RESULTS = enum.auto() - - @classmethod - def for_mediatype(cls, mediatype: str) -> 'TrovesearchFlags': - if mediatype == mediatypes.JSONAPI: - return cls.OSFMAP_JSON - if mediatype == mediatypes.JSON: - return cls.OSFMAP_JSON | cls.ONLY_RESULTS - return cls(0) # none flags - - # TODO: per-field text search in rdf # @trovesearch_by_indexstrategy.gatherer(TROVE.cardSearchText) -# def gather_cardsearch_text(focus, *, specific_index, search_params, trovesearch_flags): +# def gather_cardsearch_text(focus, *, specific_index, search_params, deriver_iri): # yield (TROVE.cardSearchText, literal(search_params.cardsearch_text)) # # # @trovesearch_by_indexstrategy.gatherer(TROVE.valueSearchText) -# def gather_valuesearch_text(focus, *, specific_index, search_params, trovesearch_flags): +# def gather_valuesearch_text(focus, *, specific_index, search_params, deriver_iri): # yield (TROVE.valueSearchText, literal(search_params.valuesearch_text)) @trovesearch_by_indexstrategy.gatherer(TROVE.propertyPath, focustype_iris={TROVE.Valuesearch}) -def gather_valuesearch_propertypath(focus, *, specific_index, search_params, trovesearch_flags): +def gather_valuesearch_propertypath(focus, *, search_params, **kwargs): yield from _multi_propertypath_twoples(search_params.valuesearch_propertypath_set) @trovesearch_by_indexstrategy.gatherer(TROVE.valueSearchFilter) -def gather_valuesearch_filter(focus, *, specific_index, search_params, trovesearch_flags): +def gather_valuesearch_filter(focus, *, search_params, **kwargs): for _filter in search_params.valuesearch_filter_set: yield (TROVE.valueSearchFilter, _filter_as_blanknode(_filter, {})) @@ -109,7 +94,7 @@ def gather_valuesearch_filter(focus, *, specific_index, search_params, trovesear TROVE.cardSearchFilter, focustype_iris={TROVE.Cardsearch}, ) -def gather_cardsearch(focus, *, specific_index, search_params, trovesearch_flags): +def gather_cardsearch(focus, *, specific_index, search_params, **kwargs): assert isinstance(search_params, CardsearchParams) # defer to the IndexStrategy implementation to do the search _cardsearch_resp = specific_index.pls_handle_cardsearch(search_params) @@ -158,7 +143,7 @@ def gather_cardsearch(focus, *, specific_index, search_params, trovesearch_flags @trovesearch_by_indexstrategy.gatherer( focustype_iris={TROVE.Valuesearch}, ) -def gather_valuesearch(focus, *, specific_index, search_params, trovesearch_flags): +def gather_valuesearch(focus, *, specific_index, search_params, **kwargs): assert isinstance(search_params, ValuesearchParams) _valuesearch_resp = specific_index.pls_handle_valuesearch(search_params) _result_page = [] @@ -212,7 +197,7 @@ def gather_valuesearch(focus, *, specific_index, search_params, trovesearch_flag @trovesearch_by_indexstrategy.gatherer( focustype_iris={TROVE.Indexcard}, ) -def gather_card(focus, *, trovesearch_flags, deriver_iri=None, **kwargs): +def gather_card(focus, *, deriver_iri, **kwargs): # TODO: batch gatherer -- load all cards in one query yield (RDF.type, DCAT.CatalogRecord) _indexcard_namespace = trove_indexcard_namespace() diff --git a/trove/views/indexcard.py b/trove/views/indexcard.py index 887aa0299..88536122b 100644 --- a/trove/views/indexcard.py +++ b/trove/views/indexcard.py @@ -1,25 +1,29 @@ from django.views import View from primitive_metadata import gather +from trove import exceptions as trove_exceptions from trove.render import get_renderer -from trove.trovesearch.trovesearch_gathering import trovesearch_by_indexstrategy, TrovesearchFlags -from trove.vocab.trove import TROVE, trove_indexcard_iri +from trove.trovesearch.trovesearch_gathering import trovesearch_by_indexstrategy +from trove.vocab.namespaces import TROVE +from trove.vocab.trove import trove_indexcard_iri class IndexcardView(View): def get(self, request, indexcard_uuid): _renderer = get_renderer(request) - _search_gathering = trovesearch_by_indexstrategy.new_gathering({ - # TODO (gather): allow omitting kwargs that go unused - 'search_params': None, - 'specific_index': None, - 'deriver_iri': ..., - 'trovesearch_flags': TrovesearchFlags.for_mediatype(_renderer.MEDIATYPE), - }) - _indexcard_iri = trove_indexcard_iri(indexcard_uuid) - _search_gathering.ask( - {}, # TODO: build from `include`/`fields` - focus=gather.Focus.new(_indexcard_iri, TROVE.Indexcard), - ) - _response_tripledict = _search_gathering.leaf_a_record() - return _renderer.render_response(_response_tripledict, _indexcard_iri) + try: + _search_gathering = trovesearch_by_indexstrategy.new_gathering({ + # TODO (gather): allow omitting kwargs that go unused + 'search_params': None, + 'specific_index': None, + 'deriver_iri': _renderer.INDEXCARD_DERIVER_IRI, + }) + _indexcard_iri = trove_indexcard_iri(indexcard_uuid) + _search_gathering.ask( + {}, # TODO: build from `include`/`fields` + focus=gather.Focus.new(_indexcard_iri, TROVE.Indexcard), + ) + _response_tripledict = _search_gathering.leaf_a_record() + return _renderer.render_response(_response_tripledict, _indexcard_iri) + except trove_exceptions.TroveError as _error: + return _renderer.render_error_response(_error) diff --git a/trove/views/search.py b/trove/views/search.py index 5511ad4d3..c303eb5b0 100644 --- a/trove/views/search.py +++ b/trove/views/search.py @@ -5,11 +5,12 @@ from primitive_metadata import gather from share.search.index_strategy import IndexStrategy +from trove import exceptions as trove_exceptions from trove.trovesearch.search_params import ( CardsearchParams, ValuesearchParams, ) -from trove.trovesearch.trovesearch_gathering import trovesearch_by_indexstrategy, TrovesearchFlags +from trove.trovesearch.trovesearch_gathering import trovesearch_by_indexstrategy from trove.vocab.namespaces import TROVE from trove.render import get_renderer @@ -44,29 +45,36 @@ class CardsearchView(View): def get(self, request): - _search_iri, _search_gathering, _renderer = _parse_request(request, CardsearchParams) - _search_gathering.ask( - DEFAULT_CARDSEARCH_ASK, # TODO: build from `include`/`fields` - focus=gather.Focus.new(_search_iri, TROVE.Cardsearch), - ) - return _renderer.render_response(_search_gathering.leaf_a_record(), _search_iri) + _renderer = get_renderer(request) + try: + _search_iri, _search_gathering = _parse_request(request, _renderer, CardsearchParams) + _search_gathering.ask( + DEFAULT_CARDSEARCH_ASK, # TODO: build from `include`/`fields` + focus=gather.Focus.new(_search_iri, TROVE.Cardsearch), + ) + return _renderer.render_response(_search_gathering.leaf_a_record(), _search_iri) + except trove_exceptions.TroveError as _error: + return _renderer.render_error_response(_error) class ValuesearchView(View): def get(self, request): - _search_iri, _search_gathering, _renderer = _parse_request(request, ValuesearchParams) - _search_gathering.ask( - DEFAULT_VALUESEARCH_ASK, # TODO: build from `include`/`fields` - focus=gather.Focus.new(_search_iri, TROVE.Valuesearch), - ) - return _renderer.render_response(_search_gathering.leaf_a_record(), _search_iri) + _renderer = get_renderer(request) + try: + _search_iri, _search_gathering = _parse_request(request, _renderer, ValuesearchParams) + _search_gathering.ask( + DEFAULT_VALUESEARCH_ASK, # TODO: build from `include`/`fields` + focus=gather.Focus.new(_search_iri, TROVE.Valuesearch), + ) + return _renderer.render_response(_search_gathering.leaf_a_record(), _search_iri) + except trove_exceptions.TroveError as _error: + return _renderer.render_error_response(_error) ### # local helpers -def _parse_request(request: http.HttpRequest, search_params_dataclass): - _renderer = get_renderer(request) +def _parse_request(request: http.HttpRequest, renderer, search_params_dataclass): _search_iri = request.build_absolute_uri() _search_params = search_params_dataclass.from_querystring( request.META['QUERY_STRING'], @@ -76,6 +84,6 @@ def _parse_request(request: http.HttpRequest, search_params_dataclass): _search_gathering = trovesearch_by_indexstrategy.new_gathering({ 'search_params': _search_params, 'specific_index': _specific_index, - 'trovesearch_flags': TrovesearchFlags.for_mediatype(_renderer.MEDIATYPE), + 'deriver_iri': renderer.INDEXCARD_DERIVER_IRI, }) - return (_search_iri, _search_gathering, _renderer) + return (_search_iri, _search_gathering)