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 #}
+
+
{% 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() %}
+
+ | {{ i }} |
+ {{ render_item_value(j, 60) }} |
+
+ {% endfor %}
+
{% 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 %}
+
+ {% endif %}
+
| {% trans %}Property{% endtrans %} |
@@ -86,14 +102,14 @@ {{ ptitle }}
{% endif %}
- | id |
- {{ data.id }} |
+ id |
+ {{ data.id }} |
{% for k, v in data['properties'].items() %}
{% if k != data['id_field'] %}
- | {{ k | striptags }} |
- {{ render_item_value(v, 80) }} |
+ {{ k | striptags }} |
+ {{ render_item_value(v, 80) }} |
{% 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 #}
+
+
+
+ {% elif v is string or v is number %}
+ {{ val | urlize() }}
+ {% elif v is mapping %}
+
+ {% for i,j in v.items() %}
+
+ | {{ i }} |
+ {{ render_item_value(j, 60) }} |
+
+ {% endfor %}
+
+ {% 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 %}
+
+ {% endif %}
+
| {% trans %}Property{% endtrans %} |
@@ -61,12 +97,12 @@ {% trans %}Assets{% endtrans %}
- | {% trans %}id{% endtrans %} |
- {{ data.id }} |
+ {% trans %}id{% endtrans %} |
+ {{ data.id }} |
{% for k, v in data['properties'].items() %}
- | {{ k }} |
+ {{ k }} |
{% if k == 'links' %}
@@ -76,7 +112,7 @@ {% trans %}Assets{% endtrans %}
|
{% else %}
- {{ v }} |
+ {{ render_item_value(v, 80) }} |
{% 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 %}