diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 6f8e3f3ac..cfb72ce13 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -667,6 +667,45 @@ templates using dataset level templating. To learn more about Jinja2 templates, context: - datetime: https://schema.org/DateTime +Injecting verbatim JSON-LD contexts +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes it is desirable to override pygeoapi's default handling of JSON and JSON-LD resources +so that the provided ``context`` entries inside ``linked-data`` are injected into the objects +exactly as provided. + +This behavior can be enabled by setting ``inject_verbatim_context`` to ``true`` inside +the Linked Data configuration for the resource: + +.. code-block:: yaml + + linked-data: + context: + - https://example.com/my-context.jsonld + inject_verbatim_context: true + +With ``inject_verbatim_context`` enabled, both JSON and JSON-LD item resources will have +a ``@context`` property with the provided linked data context entries, and no JSON-LD +manipulation will be performed by pygeoapi. + +Additionally, some semantically enabled resources may provide their own ``@id`` (i.e., URI) value, +which may be different from the one used by pygeoapi. The ``replace_id_field`` setting +inside ``linked-data`` can be used to instruct pygeoapi to override a given property +in the object with its own item URL: + +.. code-block:: yaml + + linked-data: + context: + - https://example.com/my-context.jsonld + inject_verbatim_context: true + replace_id_field: id + +In the example above, the ``id`` field will be overwritten with the item URL on +the pygeoapi service. + +.. Important:: + ``inject_verbatim_context`` must be enabled for ``replace_id_field`` to work. Validating the configuration ---------------------------- diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index a1e1bda16..e6a5e6cf8 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -956,6 +956,11 @@ def get_collection_item(api: API, request: APIRequest, # locale (or fallback default locale) l10n.set_response_language(headers, prv_locale, request.locale) + config_dataset = api.config['resources'][dataset] + linked_data = config_dataset.get('linked-data') + if linked_data: + content['linked_data'] = linked_data + if request.format == F_HTML: # render tpl_config = api.get_dataset_templates(dataset) content['title'] = l10n.translate(collections[dataset]['title'], @@ -973,7 +978,12 @@ def get_collection_item(api: API, request: APIRequest, content, request.locale) return headers, HTTPStatus.OK, content - elif request.format == F_JSONLD: + elif request.format == F_JSONLD or ( + request.format == F_JSON + and linked_data + and all(linked_data.get(k) + for k in ('context', 'inject_verbatim_context')) + ): content = geojson2jsonld( api, content, dataset, uri, (p.uri_field or 'id') ) diff --git a/pygeoapi/api/stac.py b/pygeoapi/api/stac.py index 37dfbedc9..b9565512d 100644 --- a/pygeoapi/api/stac.py +++ b/pygeoapi/api/stac.py @@ -56,7 +56,7 @@ ) from pygeoapi.util import ( filter_dict_by_key_value, get_current_datetime, get_provider_by_type, - render_j2_template, to_json + render_j2_template, to_json, get_base_url ) from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML @@ -200,6 +200,9 @@ def get_stac_path(api: API, request: APIRequest, content['links'].extend( stac_collections[dataset].get('links', [])) + linked_data = api.config['resources'][dataset].get('linked-data') + content['linked_data'] = linked_data + if request.format == F_HTML: # render content['path'] = path if 'assets' in content: # item view @@ -227,6 +230,18 @@ def get_stac_path(api: API, request: APIRequest, return headers, HTTPStatus.OK, content + elif (linked_data + and all(linked_data.get(k) + for k in ('context', 'inject_verbatim_context'))): + content = { + '@context': linked_data['context'], + **content + } + if replace_id_field := linked_data.get('replace_id_field'): + content[replace_id_field] = (f"{get_base_url(api.config)}" + f"/stac/{path}") + pass + return headers, HTTPStatus.OK, to_json(content, api.pretty_print) else: # send back file diff --git a/pygeoapi/linked_data.py b/pygeoapi/linked_data.py index 4b0edb1d1..8365e515d 100644 --- a/pygeoapi/linked_data.py +++ b/pygeoapi/linked_data.py @@ -195,59 +195,71 @@ def geojson2jsonld(cls, data: dict, dataset: str, context = linked_data.get('context', []).copy() templates = cls.get_dataset_templates(dataset) - defaultVocabulary = { - 'schema': 'https://schema.org/', - 'gsp': 'http://www.opengis.net/ont/geosparql#', - 'type': '@type' - } - - if identifier: - # Expand properties block - data.update(data.pop('properties')) - - # Include multiple geometry encodings - if (data.get('geometry') is not None): - jsonldify_geometry(data) - - data['@id'] = identifier - + if (identifier and context + and linked_data.get('inject_verbatim_context', False)): + ldjsonData = { + '@context': context, + **data + } + if replace_id_field := linked_data.get('replace_id_field'): + ldjsonData[replace_id_field] = identifier else: - # Collection of jsonld - defaultVocabulary.update({ - 'features': 'schema:itemListElement', - 'FeatureCollection': 'schema:itemList' - }) - - ds_url = url_join(cls.get_collections_url(), dataset) - data['@id'] = ds_url - - for i, feature in enumerate(data['features']): - # Get URI for each feature - identifier_ = feature.get(id_field, - feature['properties'].get(id_field, '')) - if not is_url(str(identifier_)): - identifier_ = f"{ds_url}/items/{feature['id']}" # noqa - # Include multiple geometry encodings - if feature.get('geometry') is not None: - jsonldify_geometry(feature) - - data['features'][i] = { - '@id': identifier_, - 'type': 'schema:Place', - **feature.pop('properties'), - **feature - } - - if data.get('timeStamp', False): - data['https://schema.org/sdDatePublished'] = data.pop('timeStamp') + defaultVocabulary = { + 'schema': 'https://schema.org/', + 'gsp': 'http://www.opengis.net/ont/geosparql#', + 'type': '@type' + } - data['links'] = data.pop('links') + if identifier: + # Expand properties block + data.update(data.pop('properties')) - ldjsonData = { - '@context': [defaultVocabulary, *(context or [])], - **data - } + # Include multiple geometry encodings + if (data.get('geometry') is not None): + jsonldify_geometry(data) + + data['@id'] = identifier + + else: + # Collection of jsonld + defaultVocabulary.update({ + 'features': 'schema:itemListElement', + 'FeatureCollection': 'schema:itemList' + }) + + ds_url = url_join(cls.get_collections_url(), dataset) + data['@id'] = ds_url + + for i, feature in enumerate(data['features']): + # Get URI for each feature + identifier_ = feature.get( + id_field, + feature['properties'].get(id_field, '') + ) + if not is_url(str(identifier_)): + identifier_ = f"{ds_url}/items/{feature['id']}" # noqa + + # Include multiple geometry encodings + if feature.get('geometry') is not None: + jsonldify_geometry(feature) + + data['features'][i] = { + '@id': identifier_, + 'type': 'schema:Place', + **feature.pop('properties'), + **feature + } + + if data.get('timeStamp', False): + data['https://schema.org/sdDatePublished'] = data.pop('timeStamp') + + data['links'] = data.pop('links') + + ldjsonData = { + '@context': [defaultVocabulary, *(context or [])], + **data + } if identifier: # Render jsonld template for single item diff --git a/pygeoapi/static/css/linked-data.css b/pygeoapi/static/css/linked-data.css new file mode 100644 index 000000000..031385ba5 --- /dev/null +++ b/pygeoapi/static/css/linked-data.css @@ -0,0 +1,37 @@ +.object-property.resource-loading::before, +.literal-value.resource-loading::before { + content: ""; + display: inline-block; + margin-right: 0.35em; + width: 1em; + height: 1em; + border: 2px solid rgba(0, 0, 0, 0.2); + border-top-color: black; + border-radius: 50%; + vertical-align: middle; + animation: spin 0.8s linear infinite; +} + +.object-property.resource-resolved::after, +.literal-value.resource-resolved::after { + content: "✔"; + color: green; + margin-left: 0.35em; + font-weight: bold; + vertical-align: middle; + font-family: "Arial", "Helvetica", sans-serif; +} + +.object-property.resource-error::after, +.literal-value.resource-error::after { + content: "✖"; + color: red; + margin-left: 0.35em; + font-weight: bold; + vertical-align: middle; + font-family: "Arial", "Helvetica", sans-serif; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/pygeoapi/templates/collections/items/item.html b/pygeoapi/templates/collections/items/item.html index 705f9057c..5607f047a 100644 --- a/pygeoapi/templates/collections/items/item.html +++ b/pygeoapi/templates/collections/items/item.html @@ -1,25 +1,33 @@ {% extends "_base.html" %} {% set ptitle = data['properties'][data['title_field']] or data['id'] | string %} +{% set linked_data = data.get('linked_data') %} {% block desc %}{{ data.get('properties',{}).get('description', {}) | string | truncate(250) }}{% endblock %} {% block tags %}{{ data['properties'].get('themes', [{}])[0].get('concepts', []) | join(',') }}{% endblock %} {# Optionally renders an img element, otherwise standard value or link rendering #} -{% macro render_item_value(v, width) -%} +{% macro render_item_value(v, width, additional_classes='') -%} {% set val = v | string | trim %} {% if val|length and val.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp')) %} {# Ends with image extension: render img element with link to image #} + {{ val.split('/') | last }} + {% elif v is string or v is number %} - {{ val | urlize() }} + {{ val | urlize() }} {% elif v is mapping %} - {% for i,j in v.items() %} - {{ i }}: {{ render_item_value(j, 60) }}
- {% endfor %} + + {% for i,j in v.items() %} + + + + + {% endfor %} +
{{ i }}{{ render_item_value(j, 60) }}
{% elif v is iterable %} {% for i in v %} - {{ render_item_value(i, 60) }} + {{ render_item_value(i, 60, 'array-entry') }} {% endfor %} {% else %} - {{ val | urlize() }} + {{ val | urlize() }} {% endif %} {%- endmacro %} {% block title %}{{ ptitle }}{% endblock %} @@ -35,6 +43,7 @@ {% endblock %} {% block extrahead %} + {% endblock %} @@ -71,7 +80,14 @@

{{ ptitle }}

- + {% if linked_data and linked_data.get('context') | length %} +
+ + {% trans %}Semantic lookup{% endtrans %} + +
+ {% endif %} +
@@ -86,14 +102,14 @@

{{ ptitle }}

{% endif %} - - + + {% for k, v in data['properties'].items() %} {% if k != data['id_field'] %} - - + + {% endif %} {% endfor %} @@ -135,4 +151,26 @@

{{ ptitle }}

map.addLayer(items); map.fitBounds(items.getBounds(), {maxZoom: 15}); + {% if linked_data and linked_data.get('context') | length %} + + + + {% endif %} {% endblock %} diff --git a/pygeoapi/templates/stac/item.html b/pygeoapi/templates/stac/item.html index f42525e44..830f6acee 100644 --- a/pygeoapi/templates/stac/item.html +++ b/pygeoapi/templates/stac/item.html @@ -6,9 +6,38 @@ / {{ link['title'] }} {% endfor %} {% endblock %} +{% set linked_data = data.get('linked_data') %} + +{% macro render_item_value(v, width, additional_classes='') -%} + {% set val = v | string | trim %} + {% if val|length and val.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp')) %} + {# Ends with image extension: render img element with link to image #} + + {{ val.split('/') | last }} + + {% elif v is string or v is number %} + {{ val | urlize() }} + {% elif v is mapping %} +
{% trans %}Property{% endtrans %}
id{{ data.id }}id{{ data.id }}
{{ k | striptags }}{{ render_item_value(v, 80) }}{{ k | striptags }}{{ render_item_value(v, 80) }}
+ {% for i,j in v.items() %} + + + + + {% endfor %} +
{{ i }}{{ render_item_value(j, 60) }}
+ {% elif v is iterable %} + {% for i in v %} + {{ render_item_value(i, 60, 'array-entry') }} + {% endfor %} + {% else %} + {{ val | urlize() }} + {% endif %} +{%- endmacro %} {% block extrahead %} + {% endblock %} @@ -52,7 +81,14 @@

{% trans %}Assets{% endtrans %}

- + {% if linked_data and linked_data.get('context') | length %} +
+ + {% trans %}Semantic lookup{% endtrans %} + +
+ {% endif %} +
@@ -61,12 +97,12 @@

{% trans %}Assets{% endtrans %}

- - + + {% for k, v in data['properties'].items() %} - + {% if k == 'links' %} {% else %} - + {% endif %} {% endfor %} @@ -106,4 +142,26 @@

{% trans %}Assets{% endtrans %}

map.addLayer(bbox_layer); map.fitBounds(bbox_layer.getBounds(), {maxZoom: 10}); + {% if linked_data and linked_data.get('context') | length %} + + + + {% endif %} {% endblock %}
{% trans %}Property{% endtrans %}
{% trans %}id{% endtrans %}{{ data.id }}{% trans %}id{% endtrans %}{{ data.id }}
{{ k }}{{ k }}
    @@ -76,7 +112,7 @@

    {% trans %}Assets{% endtrans %}

{{ v }}{{ render_item_value(v, 80) }}