Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/source/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +691 to +694
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this differ from the uri_field in

uri_field: uri # field corresponding to the Uniform Resource Identifier (see Linked Data section)
. Is one specific to STAC and the other specific to items?


.. 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
----------------------------
Expand Down
12 changes: 11 additions & 1 deletion pygeoapi/api/itemtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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')
)
Expand Down
17 changes: 16 additions & 1 deletion pygeoapi/api/stac.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
110 changes: 61 additions & 49 deletions pygeoapi/linked_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions pygeoapi/static/css/linked-data.css
Original file line number Diff line number Diff line change
@@ -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); }
}
62 changes: 50 additions & 12 deletions pygeoapi/templates/collections/items/item.html
Original file line number Diff line number Diff line change
@@ -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 #}
<span class="literal-value">
<a href="{{ val }}"><img src="{{ val }}" alt="{{ val.split('/') | last }}" width="{{ width }}"/></a>
</span>
{% elif v is string or v is number %}
{{ val | urlize() }}
<span class="literal-value">{{ val | urlize() }}</span>
{% elif v is mapping %}
{% for i,j in v.items() %}
<i>{{ i }}:</i> {{ render_item_value(j, 60) }}<br/>
{% endfor %}
<table class="table table-striped table-border object-table {{ additional_classes }}">
{% for i,j in v.items() %}
<tr>
<td class="object-property" data-property="{{ i }}">{{ i }}</td>
<td class="object-value">{{ render_item_value(j, 60) }}</td>
</tr>
{% endfor %}
</table>
{% elif v is iterable %}
{% for i in v %}
{{ render_item_value(i, 60) }}
{{ render_item_value(i, 60, 'array-entry') }}
{% endfor %}
{% else %}
{{ val | urlize() }}
<span class="literal-value">{{ val | urlize() }}</span>
{% endif %}
{%- endmacro %}
{% block title %}{{ ptitle }}{% endblock %}
Expand All @@ -35,6 +43,7 @@
{% endblock %}
{% block extrahead %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
<link rel="stylesheet" href="{{ config['server']['url'] }}/static/css/linked-data.css">
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
{% endblock %}

Expand Down Expand Up @@ -71,7 +80,14 @@ <h1>{{ ptitle }}</h1>
</div>
</div>
<div class="col-md-6 col-sm-12">
<table class="table table-striped table-bordered">
{% if linked_data and linked_data.get('context') | length %}
<div class="text-end mb-2">
<a href="#" class="btn btn-primary" id="semantic-lookup-btn" data-target="#item-properties-table">
{% trans %}Semantic lookup{% endtrans %}
</a>
</div>
{% endif %}
<table class="table table-striped table-bordered object-table" id="item-properties-table">
<thead>
<tr>
<th>{% trans %}Property{% endtrans %}</th>
Expand All @@ -86,14 +102,14 @@ <h1>{{ ptitle }}</h1>
</tr>
{% endif %}
<tr>
<td>id</td>
<td>{{ data.id }}</td>
<td class="object-property" data-property="id">id</td>
<td class="object-value">{{ data.id }}</td>
</tr>
{% for k, v in data['properties'].items() %}
{% if k != data['id_field'] %}
<tr>
<td>{{ k | striptags }}</td>
<td>{{ render_item_value(v, 80) }}</td>
<td class="object-property" data-property="{{ k | striptags }}">{{ k | striptags }}</td>
<td class="object-value">{{ render_item_value(v, 80) }}</td>
</tr>
{% endif %}
{% endfor %}
Expand Down Expand Up @@ -135,4 +151,26 @@ <h1>{{ ptitle }}</h1>
map.addLayer(items);
map.fitBounds(items.getBounds(), {maxZoom: 15});
</script>
{% if linked_data and linked_data.get('context') | length %}
<script src="https://cdn.jsdelivr.net/npm/rdflib@2.3.0/dist/rdflib.min.js"></script>
<script src="https://github.com/avillar/jsonld-ui-utils/releases/latest/download/jsonld-ui-utils.min.js"></script>
<script>
document.getElementById('semantic-lookup-btn').addEventListener('click', function(e) {
e.target.style.display = 'none';
var rootElem = document.getElementById('item-properties-table');
if (rootElem) {
var linkedDataConfig = {{ linked_data | tojson }}
var options = {};
if (linkedDataConfig.fallback_sparql_endpoint) {
options.fallbackSparqlEndpoint = linkedDataConfig.fallback_sparql_endpoint;
}
var context = {'@context': linkedDataConfig['context']};
jsonldUIUtils.loadContext(context)
.then(loadedContext => {
jsonldUIUtils.augment(rootElem, loadedContext, options);
});
}
});
</script>
{% endif %}
{% endblock %}
Loading