diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index bb07f1b..9a8e66a 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -147,6 +147,7 @@ default. keywords: # list of related keywords - observations - monitoring + geojsonld: true # use default geojsonld behavior (see Linked Data section) context: # linked data configuration (see Linked Data section) - datetime: https://schema.org/DateTime - vocab: https://example.com/vocab# @@ -175,6 +176,7 @@ default. name: CSV data: tests/data/obs.csv # required: the data filesystem path or URL, depending on plugin setup id_field: id # required for vector data, the field corresponding to the ID + uri_field: uri # optional field corresponding to the Uniform Resource Identifier (see Linked Data section) time_field: datetimestamp # optional field corresponding to the temporal property of the dataset title_field: foo # optional field of which property to display as title/label on HTML pages format: # optional default format @@ -232,12 +234,17 @@ The metadata for an instance is determined by the content of the `metadata`_ sec This metadata is included automatically, and is sufficient for inclusion in major indices of datasets, including the `Google Dataset Search`_. -For collections, at the level of an item or items, by default the JSON-LD representation adds: +For collections, at the level of an item or items, the default the JSON-LD representation adds: - The GeoJSON JSON-LD `vocabulary and context `_ to the ``@context``. - An ``@id`` for each item in a collection, that is the URL for that item (resolving to its HTML representation in pygeoapi) +The optional configuration options for collections, at the level of an item of items, are: + +- If ``geojsonld`` is not specified or is ``true``, the JSON-LD will conform to the default configuration presenting GeoJSON-LD. If ``geojsonld`` is ``false`` the individual item JSON-LD will conform to standard JSON-LD, and the ``@context`` will not include the geojson vocabulary, but only one specifying the ``@id`` property, the ``@type`` property, and schema.org/ vocabulary. In addition, properties block will be expanded into the main body, non-point geometry will be removed, and point-type geometry will be represented as https://schema.org geometry instead of geojson. +- If ``uri_field`` is specified, JSON-LD will be updated such that ``@id:uri_field`` for each item in a collection. + .. note:: While this is enough to provide valid RDF (as GeoJSON-LD), it does not allow the *properties* of your items to be unambiguously interpretable. diff --git a/pygeoapi/api.py b/pygeoapi/api.py index d384809..361cf9e 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -1045,6 +1045,8 @@ class API: content['collections_path'] = '/'.join(path_info.split('/')[:-2]) content['startindex'] = startindex + if p.uri_field is not None: + content['uri_field'] = p.uri_field if p.title_field is not None: content['title_field'] = p.title_field content['id_field'] = p.title_field @@ -1074,8 +1076,10 @@ class API: return headers_, 200, content elif format_ == 'jsonld': headers_['Content-Type'] = 'application/ld+json' - content = geojson2geojsonld(self.config, content, dataset) - return headers_, 200, content + content = geojson2geojsonld( + self.config, content, dataset, id_field=(p.uri_field or 'id') + ) + return headers_, 200, to_json(content, self.pretty_print) return headers_, 200, to_json(content, self.pretty_print) @@ -1147,24 +1151,25 @@ class API: msg = 'identifier not found' return self.get_exception(400, headers_, format_, 'NotFound', msg) + uri = content['properties'].get(p.uri_field) if p.uri_field else \ + '{}/collections/{}/items/{}'.format( + self.config['server']['url'], dataset, identifier) + content['links'] = [{ 'rel': 'self' if not format_ or format_ == 'json' else 'alternate', 'type': 'application/geo+json', 'title': 'This document as GeoJSON', - 'href': '{}/collections/{}/items/{}?f=json'.format( - self.config['server']['url'], dataset, identifier) + 'href': '{}?f=json'.format(uri) }, { 'rel': 'self' if format_ == 'jsonld' else 'alternate', 'type': 'application/ld+json', 'title': 'This document as RDF (JSON-LD)', - 'href': '{}/collections/{}/items/{}?f=jsonld'.format( - self.config['server']['url'], dataset, identifier) + 'href': '{}?f=jsonld'.format(uri) }, { 'rel': 'self' if format_ == 'html' else 'alternate', 'type': 'text/html', 'title': 'This document as HTML', - 'href': '{}/collections/{}/items/{}?f=html'.format( - self.config['server']['url'], dataset, identifier) + 'href': '{}?f=html'.format(uri) }, { 'rel': 'collection', 'type': 'application/json', @@ -1174,13 +1179,11 @@ class API: }, { 'rel': 'prev', 'type': 'application/geo+json', - 'href': '{}/collections/{}/items/{}'.format( - self.config['server']['url'], dataset, identifier) + 'href': uri }, { 'rel': 'next', 'type': 'application/geo+json', - 'href': '{}/collections/{}/items/{}'.format( - self.config['server']['url'], dataset, identifier) + 'href': uri } ] @@ -1189,6 +1192,8 @@ class API: content['title'] = collections[dataset]['title'] content['id_field'] = p.id_field + if p.uri_field is not None: + content['uri_field'] = p.uri_field if p.title_field is not None: content['title_field'] = p.title_field @@ -1199,8 +1204,9 @@ class API: elif format_ == 'jsonld': headers_['Content-Type'] = 'application/ld+json' content = geojson2geojsonld( - self.config, content, dataset, identifier=identifier + self.config, content, dataset, uri, (p.uri_field or 'id') ) + content = to_json(content, self.pretty_print) return headers_, 200, content return headers_, 200, to_json(content, self.pretty_print) diff --git a/pygeoapi/linked_data.py b/pygeoapi/linked_data.py index fb89c05..7b717b8 100644 --- a/pygeoapi/linked_data.py +++ b/pygeoapi/linked_data.py @@ -30,7 +30,6 @@ Returns content as linked data representations """ -import json import logging from pygeoapi.util import is_url @@ -161,7 +160,7 @@ def jsonldify_collection(cls, collection): return dataset -def geojson2geojsonld(config, data, dataset, identifier=None): +def geojson2geojsonld(config, data, dataset, identifier=None, id_field='id'): """ Render GeoJSON-LD from a GeoJSON base. Inserts a @context that can be read from, and extended by, the pygeoapi configuration for a particular @@ -171,35 +170,96 @@ def geojson2geojsonld(config, data, dataset, identifier=None): :param data: dict of data: :param dataset: dataset identifier :param identifier: item identifier (optional) + :param id_field: item identifier_field (optional) :returns: string of rendered JSON (GeoJSON-LD) """ context = config['resources'][dataset].get('context', []) - data['id'] = ( - '{}/collections/{}/items/{}' if identifier - else '{}/collections/{}/items' - ).format( - *[config['server']['url'], dataset, identifier] - ) + geojsonld = config['resources'][dataset].get('geojsonld', True) + + if identifier: + # Single geojsonld + if not geojsonld: + data, geocontext = make_jsonld(data) + data[id_field] = identifier + else: + data['id'] = identifier + + else: + # Collection of geojsonld + data['@id'] = '{}/collections/{}/items/'.format( + config['server']['url'], dataset) + for i, feature in enumerate(data['features']): + identifier = feature.get(id_field, + feature['properties'].get(id_field, '')) + if not is_url(str(identifier)): + identifier = '{}/collections/{}/items/{}'.format( + config['server']['url'], dataset, feature['id']) + + if not geojsonld: + feature, geocontext = make_jsonld(feature) + geocontext.append({ + "features": "schema:itemListElement", + "FeatureCollection": "schema:itemList" + }) + # Note: @id or https://schema.org/url, both or something else? + feature[id_field] = identifier + else: + feature['id'] = identifier + + data['features'][i] = feature + if data.get('timeStamp', False): data['https://schema.org/sdDatePublished'] = data.pop('timeStamp') - defaultVocabulary = "https://geojson.org/geojson-ld/geojson-context.jsonld" - ldjsonData = { - "@context": [defaultVocabulary, *(context or [])], - **data - } - isCollection = identifier is None - if isCollection: - for i, feature in enumerate(data['features']): - featureId = feature.get( - 'id', None - ) or feature.get('properties', {}).get('id', None) - if featureId is None: - continue - # Note: @id or https://schema.org/url or both or something else? - if is_url(str(featureId)): - feature['id'] = featureId - else: - feature['id'] = '{}/{}'.format(data['id'], featureId) - return json.dumps(ldjsonData) + defaultVocabulary = "https://geojson.org/geojson-ld/geojson-context.jsonld" + if not geojsonld: + ldjsonData = { + "@context": [ + { + "schema": "https://schema.org/", + id_field: "@id", + "type": "@type", + }, + *(context or []), + *(geocontext or []) + ], + **data + } + else: + ldjsonData = { + "@context": [ + defaultVocabulary, + *(context or []) + ], + **data + } + + return ldjsonData + + +def make_jsonld(feature): + # Expand properties block + feature = {**feature, **feature.get('properties')} + feature.pop('properties') + + feature['type'] = 'schema:Place' + + # Remove non-point geometry + if feature.get('geometry').get('type').lower() != 'point': + feature.pop('geometry') + geocontext = [] + else: + feature.get('geometry').update({ + "lat": feature.get('geometry').get('coordinates')[1], + "long": feature.get('geometry').get('coordinates')[0] + }) + feature.get('geometry').pop('coordinates') + geocontext = [{ + "geometry": "schema:geo", + "Point": "schema:GeoCoordinates", + "lat": "schema:latitude", + "long": "schema:longitude" + }, ] + + return feature, geocontext diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py index c477091..24ef993 100644 --- a/pygeoapi/provider/base.py +++ b/pygeoapi/provider/base.py @@ -53,6 +53,7 @@ class BaseProvider: self.options = provider_def.get('options', None) self.id_field = provider_def.get('id_field', None) + self.uri_field = provider_def.get('uri_field', None) self.x_field = provider_def.get('x_field', None) self.y_field = provider_def.get('y_field', None) self.time_field = provider_def.get('time_field') diff --git a/pygeoapi/templates/collections/items/index.html b/pygeoapi/templates/collections/items/index.html index 390b5c4..451f73b 100644 --- a/pygeoapi/templates/collections/items/index.html +++ b/pygeoapi/templates/collections/items/index.html @@ -73,13 +73,16 @@ + {% if data.get('uri_field') %} + + {% endif %} {% if data['title_field'] %} {% endif %} {% for k, v in data['features'][0]['properties'].items() %} {# start with id & title then take first 5 columns for table #} - {% if loop.index < 5 and k != data['id_field'] and k != data['title_field'] %} + {% if loop.index < 5 and k not in [data['id_field'], data['title_field'], data['uri_field'], 'extent'] %} {% endif %} {% endfor %} @@ -88,12 +91,15 @@ {% for ft in data['features'] %} + {% if data.get('uri_field') %} + + {% endif %} {% if data['title_field'] %} {% endif %} {% for k, v in ft['properties'].items() %} - {% if loop.index < 5 and k not in [data['id_field'], data['title_field'], 'extent'] %} + {% if loop.index < 5 and k not in [data['id_field'], data['title_field'], data['uri_field'], 'extent'] %} {% endif %} {% endfor %} diff --git a/pygeoapi/templates/collections/items/item.html b/pygeoapi/templates/collections/items/item.html index 1fb5f74..5c0a819 100644 --- a/pygeoapi/templates/collections/items/item.html +++ b/pygeoapi/templates/collections/items/item.html @@ -77,6 +77,12 @@ + {% if data.uri_field %} + + + + + {% endif %} @@ -86,7 +92,7 @@ {% if k in ['links', 'associations'] %} -
{{ data['uri_field'] }}id{{ data['title_field'] }}{{ k }}
{{ft['properties'][data.get('uri_field')]}}{{ ft.id | string | truncate( 12 ) }}{{ ft['properties'][data['title_field']] | string | truncate( 35 ) }}{{ v | string | truncate( 35 ) }}
{{ data.uri_field }}{{ data['properties'].pop(data.uri_field) }}
id {{ data.id }}
{{ k }} +
    {% for l in v %} {% if l['href'] %}