diff --git a/docker/default.config.yml b/docker/default.config.yml index 821a87c..a63203a 100644 --- a/docker/default.config.yml +++ b/docker/default.config.yml @@ -97,11 +97,12 @@ resources: keywords: - observations - monitoring - context: - - datetime: https://schema.org/DateTime - - vocab: https://example.com/vocab# - stn_id: "vocab:stn_id" - value: "vocab:value" + linked-data: + context: + - datetime: https://schema.org/DateTime + - vocab: https://example.com/vocab# + stn_id: "vocab:stn_id" + value: "vocab:value" links: - type: text/csv rel: canonical diff --git a/docker/examples/sensorthings/brgm.sta.pygeoapi.config.yml b/docker/examples/sensorthings/brgm.sta.pygeoapi.config.yml index 06b5111..03f7a1a 100644 --- a/docker/examples/sensorthings/brgm.sta.pygeoapi.config.yml +++ b/docker/examples/sensorthings/brgm.sta.pygeoapi.config.yml @@ -103,11 +103,12 @@ resources: - Things - SensorThings - BRGM - context: - - sosa: "http://www.w3.org/ns/sosa/" - ssn: "http://www.w3.org/ns/ssn/" - Datastreams: sosa:ObservationCollection - name: schema:name + linked-data: + context: + - sosa: "http://www.w3.org/ns/sosa/" + ssn: "http://www.w3.org/ns/ssn/" + Datastreams: sosa:ObservationCollection + name: schema:name links: - type: application/html rel: canonical @@ -140,12 +141,13 @@ resources: - Datastreams - SensorThings - BRGM - context: - - sosa: http://www.w3.org/ns/sosa/ - ssn: http://www.w3.org/ns/ssn/ - Observations: sosa:hasMember - Thing: sosa:hasFeatureOfInterest - name: schema:name + linked-data: + context: + - sosa: http://www.w3.org/ns/sosa/ + ssn: http://www.w3.org/ns/ssn/ + Observations: sosa:hasMember + Thing: sosa:hasFeatureOfInterest + name: schema:name links: - type: application/html rel: canonical @@ -181,11 +183,12 @@ resources: - Observations - SensorThings - BRGM - context: - - sosa: http://www.w3.org/ns/sosa/ - ssn: http://www.w3.org/ns/ssn/ - Datastream: sosa:isMemberOf - name: schema:name + linked-data: + context: + - sosa: http://www.w3.org/ns/sosa/ + ssn: http://www.w3.org/ns/ssn/ + Datastream: sosa:isMemberOf + name: schema:name links: - type: application/html rel: canonical diff --git a/docker/examples/sensorthings/iow.sta.pygeoapi.config.yml b/docker/examples/sensorthings/iow.sta.pygeoapi.config.yml index ab1cdf4..f01b9db 100644 --- a/docker/examples/sensorthings/iow.sta.pygeoapi.config.yml +++ b/docker/examples/sensorthings/iow.sta.pygeoapi.config.yml @@ -97,11 +97,12 @@ resources: - Things - SensorThings - IoW - context: - - sosa: "http://www.w3.org/ns/sosa/" - ssn: "http://www.w3.org/ns/ssn/" - Datastreams: sosa:ObservationCollection - name: schema:name + linked-data: + context: + - sosa: "http://www.w3.org/ns/sosa/" + ssn: "http://www.w3.org/ns/ssn/" + Datastreams: sosa:ObservationCollection + name: schema:name links: - type: application/html rel: canonical @@ -134,12 +135,13 @@ resources: - Datastreams - SensorThings - IoW - context: - - sosa: http://www.w3.org/ns/sosa/ - ssn: http://www.w3.org/ns/ssn/ - Observations: sosa:hasMember - Thing: sosa:hasFeatureOfInterest - name: schema:name + linked-data: + context: + - sosa: http://www.w3.org/ns/sosa/ + ssn: http://www.w3.org/ns/ssn/ + Observations: sosa:hasMember + Thing: sosa:hasFeatureOfInterest + name: schema:name links: - type: application/html rel: canonical @@ -176,11 +178,12 @@ resources: - Observations - SensorThings - IoW - context: - - sosa: http://www.w3.org/ns/sosa/ - ssn: http://www.w3.org/ns/ssn/ - Datastream: sosa:isMemberOf - name: schema:name + linked-data: + context: + - sosa: http://www.w3.org/ns/sosa/ + ssn: http://www.w3.org/ns/ssn/ + Datastream: sosa:isMemberOf + name: schema:name links: - type: application/html rel: canonical diff --git a/docker/examples/sensorthings/sta.pygeoapi.config.yml b/docker/examples/sensorthings/sta.pygeoapi.config.yml index 5f035ff..c60b4da 100644 --- a/docker/examples/sensorthings/sta.pygeoapi.config.yml +++ b/docker/examples/sensorthings/sta.pygeoapi.config.yml @@ -97,11 +97,12 @@ resources: keywords: - Things - SensorThings - context: - - sosa: "http://www.w3.org/ns/sosa/" - ssn: "http://www.w3.org/ns/ssn/" - Datastreams: sosa:ObservationCollection - name: schema:name + linked-data: + context: + - sosa: "http://www.w3.org/ns/sosa/" + ssn: "http://www.w3.org/ns/ssn/" + Datastreams: sosa:ObservationCollection + name: schema:name links: - type: application/html rel: canonical @@ -132,12 +133,13 @@ resources: keywords: - Datastreams - SensorThings - context: - - sosa: http://www.w3.org/ns/sosa/ - ssn: http://www.w3.org/ns/ssn/ - Observations: sosa:hasMember - Thing: sosa:hasFeatureOfInterest - name: schema:name + linked-data: + context: + - sosa: http://www.w3.org/ns/sosa/ + ssn: http://www.w3.org/ns/ssn/ + Observations: sosa:hasMember + Thing: sosa:hasFeatureOfInterest + name: schema:name links: - type: application/html rel: canonical @@ -172,11 +174,12 @@ resources: keywords: - Observations - SensorThings - context: - - sosa: http://www.w3.org/ns/sosa/ - ssn: http://www.w3.org/ns/ssn/ - Datastream: sosa:isMemberOf - name: schema:name + linked-data: + context: + - sosa: http://www.w3.org/ns/sosa/ + ssn: http://www.w3.org/ns/ssn/ + Datastream: sosa:isMemberOf + name: schema:name links: - type: application/html rel: canonical diff --git a/docker/examples/skin/skin.config.yml b/docker/examples/skin/skin.config.yml index af63b4e..babc379 100644 --- a/docker/examples/skin/skin.config.yml +++ b/docker/examples/skin/skin.config.yml @@ -108,11 +108,12 @@ resources: keywords: - observations - monitoring - context: - - datetime: https://schema.org/DateTime - - vocab: https://example.com/vocab# - stn_id: "vocab:stn_id" - value: "vocab:value" + linked-data: + context: + - datetime: https://schema.org/DateTime + - vocab: https://example.com/vocab# + stn_id: "vocab:stn_id" + value: "vocab:value" links: - type: text/csv rel: canonical diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 8120ad9..e267731 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -47,7 +47,7 @@ The ``server`` section provides directives on binding and high level tuning. limit: 10 # server limit on number of items to return templates: # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates - path: /path/to/jinja2/templates/folder # path to templates folder containing the jinja2 template HTML files + path: /path/to/jinja2/templates/folder # path to templates folder containing the Jinja2 template HTML files static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template map: # leaflet map setup for HTML pages @@ -128,7 +128,7 @@ The ``resource.type`` property is required. Allowed types are: - ``process`` - ``stac-collection`` -The ``providers`` block is a list of 1..n providers with which to operate the data on. Each +The ``providers`` block is a list of 1..n providers with which to operate the data on. Each provider requires a ``type`` property. Allowed types are: - ``feature`` @@ -150,11 +150,13 @@ default. keywords: # list of related keywords - observations - monitoring - context: # linked data configuration (see Linked Data section) - - datetime: https://schema.org/DateTime - - vocab: https://example.com/vocab# - stn_id: "vocab:stn_id" - value: "vocab:value" + linked-data: # linked data configuration (see Linked Data section) + item_template: tests/data/base.jsonld + context: + - datetime: https://schema.org/DateTime + - vocab: https://example.com/vocab# + stn_id: "vocab:stn_id" + value: "vocab:value" links: # list of 1..n related links - type: text/csv # MIME type rel: canonical # link relations per https://www.iana.org/assignments/link-relations/link-relations.xhtml @@ -351,7 +353,7 @@ For collections, at the level of item, the default JSON-LD representation adds: - An ``@id`` for the item, which is the URL for that item. If uri_field is specified, it is used, otherwise the URL is to its HTML representation in pygeoapi. -- Separate GeoSPARQL/WKT and `schema.org/geo` versions of the geometry. `schema.org/geo` +- Separate GeoSPARQL/WKT and `schema.org/geo` versions of the geometry. `schema.org/geo` only supports point, line, and polygon geometries. Multipart lines are merged into a single line. The rest of the multipart geometries are transformed reduced and into a polygon via unary union or convex hull transform. @@ -377,7 +379,8 @@ The default pygeoapi configuration includes an example for the ``obs`` sample da .. code-block:: yaml - context: + linked-data: + context: - datetime: https://schema.org/DateTime - vocab: https://example.com/vocab# stn_id: "vocab:stn_id" @@ -389,7 +392,8 @@ one with terms defined by schema.org: .. code-block:: yaml - context: + linked-data: + context: - schema: https://schema.org/ stn_id: schema:identifer datetime: @@ -411,32 +415,52 @@ by the dataset provider, not pygeoapi. An example of a data provider that includes relationships between items is the SensorThings API provider. SensorThings API, by default, has relationships between entities within its data model. -Setting the ``intralink`` field of the SensorThings provider to ``true`` sets pygeoapi -to represent the relationship between configured entities as intra-pygeoapi links or URIs. -This relationship can further be maintained in the JSON-LD structured data using the appropiate +Setting the ``intralink`` field of the SensorThings provider to ``true`` sets pygeoapi +to represent the relationship between configured entities as intra-pygeoapi links or URIs. +This relationship can further be maintained in the JSON-LD structured data using the appropiate ``@context`` with the sosa/ssn ontology. For example: .. code-block:: yaml Things: - context: + linked-data: + context: - sosa: "http://www.w3.org/ns/sosa/" ssn: "http://www.w3.org/ns/ssn/" Datastreams: sosa:ObservationCollection Datastreams: - context: + linked-data: + context: - sosa: "http://www.w3.org/ns/sosa/" ssn: "http://www.w3.org/ns/ssn/" Observations: sosa:hasMember Thing: sosa:hasFeatureOfInterest Observations: - context: + linked-data: + context: - sosa: "http://www.w3.org/ns/sosa/" ssn: "http://www.w3.org/ns/ssn/" Datastream: sosa:isMemberOf +Sometimes, the JSON-LD desired for an individual feature in a collection is more complicated than can be achieved by +aliasing properties using a context. In thise case, it is possible to specify a Jinja2 template. When ``item_template`` +is defined for a feature collection, the json-ld prepared by pygeoapi will be used to render the Jinja2 template +specified by the path. The path specified can be absolute or relative to pygeoapi's template folder. For even more +deployment flexibility, the path can be specified with string interpolation of environment variables. + + +.. code-block:: yaml + + linked-data: + item_template: tests/data/base.jsonld + context: + - datetime: https://schema.org/DateTime + +.. note:: + The template ``tests/data/base.jsonld`` renders the unmodified JSON-LD. For more information on the capacities + of Jinja2 templates, see :ref:`html-templating`. Summary ------- diff --git a/examples/django/sample_project/pygeoapi-config.yml b/examples/django/sample_project/pygeoapi-config.yml index 8b7515b..0e11472 100644 --- a/examples/django/sample_project/pygeoapi-config.yml +++ b/examples/django/sample_project/pygeoapi-config.yml @@ -108,11 +108,12 @@ resources: keywords: - observations - monitoring - context: - - datetime: https://schema.org/DateTime - - vocab: https://example.com/vocab# - stn_id: "vocab:stn_id" - value: "vocab:value" + linked-data: + context: + - datetime: https://schema.org/DateTime + - vocab: https://example.com/vocab# + stn_id: "vocab:stn_id" + value: "vocab:value" links: - type: text/csv rel: canonical diff --git a/pygeoapi-config.yml b/pygeoapi-config.yml index 8598d2d..fd42141 100644 --- a/pygeoapi-config.yml +++ b/pygeoapi-config.yml @@ -108,11 +108,12 @@ resources: keywords: - observations - monitoring - context: - - datetime: https://schema.org/DateTime - - vocab: https://example.com/vocab# - stn_id: "vocab:stn_id" - value: "vocab:value" + linked-data: + context: + - datetime: https://schema.org/DateTime + - vocab: https://example.com/vocab# + stn_id: "vocab:stn_id" + value: "vocab:value" links: - type: text/csv rel: canonical diff --git a/pygeoapi/linked_data.py b/pygeoapi/linked_data.py index b50564c..0396f0f 100644 --- a/pygeoapi/linked_data.py +++ b/pygeoapi/linked_data.py @@ -31,10 +31,11 @@ Returns content as linked data representations """ +import json import logging from typing import Callable -from pygeoapi.util import is_url +from pygeoapi.util import is_url, render_j2_template from pygeoapi import l10n from shapely.geometry import shape from shapely.ops import unary_union @@ -188,17 +189,21 @@ def geojson2jsonld(config: dict, data: dict, dataset: str, :returns: string of rendered JSON (GeoJSON-LD) """ - context = config['resources'][dataset].get('context', []).copy() + LOGGER.debug('Fetching context and template from resource configuration') + jsonld = config['resources'][dataset].get('linked-data', {}) + + context = jsonld.get('context', []).copy() + template = jsonld.get('item_template', None) + defaultVocabulary = { 'schema': 'https://schema.org/', - id_field: '@id', 'type': '@type' } if identifier: # Single jsonld defaultVocabulary.update({ - 'geosparql': 'http://www.opengis.net/ont/geosparql#' + 'gsp': 'http://www.opengis.net/ont/geosparql#' }) # Expand properties block @@ -207,7 +212,7 @@ def geojson2jsonld(config: dict, data: dict, dataset: str, # Include multiple geometry encodings data['type'] = 'schema:Place' jsonldify_geometry(data) - data[id_field] = identifier + data['@id'] = identifier else: # Collection of jsonld @@ -223,10 +228,10 @@ def geojson2jsonld(config: dict, data: dict, dataset: str, identifier = feature.get(id_field, feature['properties'].get(id_field, '')) if not is_url(str(identifier)): - identifier = f"config['server']['url']/collections/{dataset}/items/{feature['id']}" # noqa + identifier = f"{config['server']['url']}/collections/{dataset}/items/{feature['id']}" # noqa data['features'][i] = { - id_field: identifier, + '@id': identifier, 'type': 'schema:Place' } @@ -240,7 +245,15 @@ def geojson2jsonld(config: dict, data: dict, dataset: str, **data } - return ldjsonData + if None in (template, identifier): + return ldjsonData + else: + # Render jsonld template for single item with template configured + LOGGER.debug(f'Rendering JSON-LD template: {template}') + content = render_j2_template( + config, template, ldjsonData) + ldjsonData = json.loads(content) + return ldjsonData def jsonldify_geometry(feature: dict) -> None: @@ -260,9 +273,9 @@ def jsonldify_geometry(feature: dict) -> None: feature['geometry'] = feature.pop('geometry') # Geosparql geometry - feature['geosparql:hasGeometry'] = { + feature['gsp:hasGeometry'] = { '@type': f'http://www.opengis.net/ont/sf#{geom.geom_type}', - 'geosparql:asWKT': { + 'gsp:asWKT': { '@type': 'http://www.opengis.net/ont/geosparql#wktLiteral', '@value': f'{geom.wkt}' } diff --git a/pygeoapi/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/schemas/config/pygeoapi-config-0.x.yml index 30b86e2..b08119d 100644 --- a/pygeoapi/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/schemas/config/pygeoapi-config-0.x.yml @@ -55,7 +55,7 @@ properties: properties: path: type: string - description: path to templates folder containing the jinja2 template HTML files + description: path to templates folder containing the Jinja2 template HTML files static: type: string description: path to static folder containing css, js, images and other static files referenced by the template @@ -264,16 +264,23 @@ properties: keywords: $ref: '#/definitions/i18n_array' description: list of keywords about the service - context: - type: array - description: linked data configuration - items: - type: object - patternProperties: - "^.*$": - anyOf: - - type: string - - type: object + linked-data: + type: object + description: linked data configuration + properties: + item_template: + type: string + description: path to JSON-LD Jinja2 template + context: + type: array + description: additional JSON-LD context + items: + type: object + patternProperties: + "^.*$": + anyOf: + - type: string + - type: object links: type: array description: list of related links diff --git a/pygeoapi/util.py b/pygeoapi/util.py index a7eb4dd..3cbebf2 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -49,7 +49,6 @@ from shapely.geometry import Polygon import dateutil.parser from jinja2 import Environment, FileSystemLoader, select_autoescape from babel.support import Translations -from jinja2.exceptions import TemplateNotFound import yaml from pygeoapi import __version__ @@ -328,22 +327,19 @@ def render_j2_template(config: dict, template: Path, :returns: string of rendered template """ - custom_templates = False + template_paths = {TEMPLATES, '.'} try: - templates_path = config['server']['templates']['path'] - env = Environment(loader=FileSystemLoader(templates_path), - extensions=['jinja2.ext.i18n', - 'jinja2.ext.autoescape'], - autoescape=select_autoescape(['html', 'xml'])) - custom_templates = True - LOGGER.debug(f'using custom templates: {templates_path}') + templates = config['server']['templates']['path'] + template_paths.add(templates) + LOGGER.debug(f'using custom templates: {templates}') except (KeyError, TypeError): - env = Environment(loader=FileSystemLoader(TEMPLATES), - extensions=['jinja2.ext.i18n', - 'jinja2.ext.autoescape'], - autoescape=select_autoescape(['html', 'xml'])) LOGGER.debug(f'using default templates: {TEMPLATES}') + env = Environment(loader=FileSystemLoader(template_paths), + extensions=['jinja2.ext.i18n', + 'jinja2.ext.autoescape'], + autoescape=select_autoescape(['html', 'xml'])) + env.filters['to_json'] = to_json env.filters['format_datetime'] = format_datetime env.filters['format_duration'] = format_duration @@ -362,18 +358,7 @@ def render_j2_template(config: dict, template: Path, translations = Translations.load('locale', [locale_]) env.install_gettext_translations(translations) - try: - template = env.get_template(template) - except TemplateNotFound as err: - if custom_templates: - LOGGER.debug(err) - LOGGER.debug('Custom template not found; using default') - env = Environment(loader=FileSystemLoader(TEMPLATES), - extensions=['jinja2.ext.i18n']) - env.install_gettext_translations(translations) - template = env.get_template(template) - else: - raise + template = env.get_template(template) return template.render(config=l10n.translate_struct(config, locale_, True), data=data, locale=locale_, version=__version__) diff --git a/tests/data/base.jsonld b/tests/data/base.jsonld new file mode 100644 index 0000000..6086085 --- /dev/null +++ b/tests/data/base.jsonld @@ -0,0 +1 @@ +{{ data | to_json | safe }} diff --git a/tests/pygeoapi-test-config-hidden-resources.yml b/tests/pygeoapi-test-config-hidden-resources.yml index ff46d86..5be7a67 100644 --- a/tests/pygeoapi-test-config-hidden-resources.yml +++ b/tests/pygeoapi-test-config-hidden-resources.yml @@ -121,17 +121,18 @@ resources: title: data href: https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv hreflang: en-US - context: - - schema: https://schema.org/ - stn_id: - "@id": schema:identifier - "@type": schema:Text - datetime: - "@type": schema:DateTime - "@id": schema:observationDate - value: - "@type": schema:Number - "@id": schema:QuantitativeValue + linked-data: + context: + - schema: https://schema.org/ + stn_id: + "@id": schema:identifier + "@type": schema:Text + datetime: + "@type": schema:DateTime + "@id": schema:observationDate + value: + "@type": schema:Number + "@id": schema:QuantitativeValue extents: spatial: bbox: [-180,-90,180,90] diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 9ad806b..09e90f4 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -120,17 +120,18 @@ resources: title: data href: https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv hreflang: en-US - context: - - schema: https://schema.org/ - stn_id: - "@id": schema:identifier - "@type": schema:Text - datetime: - "@type": schema:DateTime - "@id": schema:observationDate - value: - "@type": schema:Number - "@id": schema:QuantitativeValue + linked-data: + context: + - schema: https://schema.org/ + stn_id: + "@id": schema:identifier + "@type": schema:Text + datetime: + "@type": schema:DateTime + "@id": schema:observationDate + value: + "@type": schema:Number + "@id": schema:QuantitativeValue extents: spatial: bbox: [-180,-90,180,90] @@ -285,6 +286,8 @@ resources: title: data source href: https://en.wikipedia.org/wiki/GeoJSON hreflang: en-US + linked-data: + item_template: tests/data/base.jsonld extents: spatial: bbox: [-180,-90,180,90] diff --git a/tests/test_api.py b/tests/test_api.py index 45b92c1..1c47e01 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1109,7 +1109,7 @@ def test_get_collection_item_json_ld(config, api_): feature = json.loads(response) assert '@context' in feature assert all((f in feature['@context'][0] for - f in ('schema', 'type', 'geosparql'))) + f in ('schema', 'type', 'gsp'))) assert len(feature['@context']) == 1 assert 'schema' in feature['@context'][0] assert feature['@context'][0]['schema'] == 'https://schema.org/'