From b8bfc4ecaf1df5295a8edbc4d663b8a3a5bc5b44 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 15 Jan 2026 14:21:16 -0500 Subject: [PATCH 01/41] TinyDB: fix spatial queries against null geometries --- pygeoapi/provider/tinydb_.py | 14 +- tests/data/README.md | 5 + .../sample-records.tinydb | 376 ++++++++++++++++++ .../test_tinydb_catalogue_provider.py | 22 +- 4 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 tests/data/dutch-nationaalgeoregister/sample-records.tinydb diff --git a/pygeoapi/provider/tinydb_.py b/pygeoapi/provider/tinydb_.py index f2fbba228..5453f70c8 100644 --- a/pygeoapi/provider/tinydb_.py +++ b/pygeoapi/provider/tinydb_.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -448,7 +448,7 @@ def __repr__(self): return f' {self.data}' -def bbox_intersects(record_geometry, input_bbox): +def bbox_intersects(record_geometry, input_bbox) -> bool: """ Manual bbox intersection calculation @@ -458,7 +458,15 @@ def bbox_intersects(record_geometry, input_bbox): :returns: `bool` of whether the record_bbox intersects input_bbox """ - bbox1 = list(shape(record_geometry).bounds) + if record_geometry is None: + LOGGER.debug('Record geometry is none; skipping') + return False + + try: + bbox1 = list(shape(record_geometry).bounds) + except Exception as err: + LOGGER.debug(f'Invalid geometry: {err}') + return False bbox2 = [float(c) for c in input_bbox.split(',')] diff --git a/tests/data/README.md b/tests/data/README.md index 96b2fed8f..7363fb500 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -106,3 +106,8 @@ This directory provides test data to demonstrate functionality. - source: [Canadian National Water Data Archive](https://www.canada.ca/en/environment-climate-change/services/water-overview/quantity/monitoring/survey/data-products-services/national-archive-hydat.html) as extracted from the [MSC GeoMet OGC API](https://eccc-msc.github.io/open-data/msc-geomet/web-services_en/#ogc-api-features) service - URL: https://www.canada.ca/en/environment-climate-change/services/water-overview/quantity/monitoring/survey/data-products-services/national-archive-hydat.html - License: https://eccc-msc.github.io/open-data/licence/readme_en + +### `dutch-nationaalgeoregister/sample-records.tinydb` +- source: Dutch National Georegister +- URL: https://nationaalgeoregister.nl +- License: CC0: https://creativecommons.org/share-your-work/public-domain/cc0 diff --git a/tests/data/dutch-nationaalgeoregister/sample-records.tinydb b/tests/data/dutch-nationaalgeoregister/sample-records.tinydb new file mode 100644 index 000000000..51069154f --- /dev/null +++ b/tests/data/dutch-nationaalgeoregister/sample-records.tinydb @@ -0,0 +1,376 @@ +{ + "_default": { + "1": { + "id": "35149dfb-31d3-431c-a8bc-12a4034dac48", + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/record-core" + ], + "type": "Feature", + "time": { + "interval": [ + null, + null + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.690751953125, + 52.358740234375 + ], + [ + 4.690751953125, + 52.6333984375 + ], + [ + 5.020341796875, + 52.6333984375 + ], + [ + 5.020341796875, + 52.358740234375 + ], + [ + 4.690751953125, + 52.358740234375 + ] + ] + ] + }, + "properties": { + "created": "2021-12-08Z", + "updated": "2025-06-16T12:39:57Z", + "type": "dataset", + "title": "Kaartboeck 1635", + "description": "Data uit kaartboeken van de periode 1635 tot 1775. De kaartboeken werden door het waterschap gebruikt om er op toe te zien dat de eigenaren geen water in beslag namen door demping.\nDe percelen op de kaart zijn naar de huidige maatstaven vrij nauwkeurig gemeten en voorzien van een administratie met de eigenaren. bijzondere locaties van molens werven en beroepen worden in de boeken vermeld. Alle 97 kaarten aan een geven een zeer gedetailleerd beeld van de Voorzaan, Nieuwe Haven en de Achterzaan. De bladen Oost en West van de zaan zijn vrij nauwkeurig. De bladen aan de Voorzaan zijn een schetsmatige weergave van de situatie. De kaart van de Nieuwe Haven si weer nauwkeurig te noemen.", + "contacts": [ + { + "name": "Team Geo", + "organization": "Gemeente Zaanstad", + "phones": [ + { + "value": "14 075" + } + ], + "emails": [ + { + "value": "geo-informatie@zaanstad.nl" + } + ], + "addresses": [ + { + "deliveryPoint": [ + "Stadhuisplein 100" + ], + "city": [ + "Zaanstad" + ], + "administrativeArea": [ + "Noord-Holland" + ], + "postalCode": [ + "1506 MZ" + ], + "country": [ + "Netherlands" + ] + } + ], + "roles": [ + "custodian" + ] + } + ], + "externalIds": [ + { + "scheme": "default", + "value": "35149dfb-31d3-431c-a8bc-12a4034dac48" + } + ], + "themes": [ + { + "concepts": [ + { + "id": "ARGEOLOGIE" + }, + { + "id": "MONUMENTEN" + }, + { + "id": "KADASTER" + }, + { + "id": "KAARTBOEK" + }, + { + "id": "KAARTBOECK" + }, + { + "id": "HISTORIE" + } + ] + } + ], + "_metadata-anytext": "35149dfb-31d3-431c-a8bc-12a4034dac48 Team Geo Gemeente Zaanstad Informatie Beheer en Techniek 14 075 Stadhuisplein 100 Zaanstad Noord-Holland 1506 MZ Netherlands geo-informatie@zaanstad.nl ISO 19115 Nederlands metadata profiel op ISO 19115 voor geografie 1.3.1 28992 EPSG Kaartboeck 1635 56d832e1-7e33-4e60-a01a-6bdcd716aaa0 Eigenaren en percelen langs de Zaan en Voorzaan tussen 1635 en 1775 Data uit kaartboeken van de periode 1635 tot 1775. De kaartboeken werden door het waterschap gebruikt om er op toe te zien dat de eigenaren geen water in beslag namen door demping.\nDe percelen op de kaart zijn naar de huidige maatstaven vrij nauwkeurig gemeten en voorzien van een administratie met de eigenaren. bijzondere locaties van molens werven en beroepen worden in de boeken vermeld. Alle 97 kaarten aan een geven een zeer gedetailleerd beeld van de Voorzaan, Nieuwe Haven en de Achterzaan. De bladen Oost en West van de zaan zijn vrij nauwkeurig. De bladen aan de Voorzaan zijn een schetsmatige weergave van de situatie. De kaart van de Nieuwe Haven si weer nauwkeurig te noemen. Archeologisch onderzoek Kleij, Piet Gemeente Zaanstad, monumenten en argeologie Inhoudelijk contactpersoon 14 075 Stadhuisplein 100 Zaanstad Noord-Holland 1506 MZ Nederland geo-informatie@zaanstad.nl WWW:LINK-1.0-http--link Profielsite (intranet) https://geo.zaanstad.nl:443/geonetwork/srv/eng/resources.get?uuid=35149dfb-31d3-431c-a8bc-12a4034dac48&fname=monumenten-kaartboeck_s.png thumbnail png https://geo.zaanstad.nl:443/geonetwork/srv/eng/resources.get?uuid=35149dfb-31d3-431c-a8bc-12a4034dac48&fname=monumenten-kaartboeck.jpg large_thumbnail jpg ARGEOLOGIE MONUMENTEN KADASTER KAARTBOEK KAARTBOECK HISTORIE Niet geschikt voor commercieel gebruik Niet voor commercieel gebruik en naamsvermelding verplicht; Gemeente Zaanstad https://creativecommons.org/licenses/by-nc/4.0/ Geen beperkingen bekend dut WMS 1.1.1 OGC:WMS geo:kaartboeck geo:kaartboeck OGC:WFS geo:kaartboeck download download De dataset is afkomstig uit diverse kaartboeken uit verschillende periode." + }, + "links": [ + { + "href": "https://maps-intern.zaanstad.gem.local/geoserver/wms?SERVICE=WMS", + "rel": "item", + "title": "geo:kaartboeck", + "type": "OGC:WMS" + }, + { + "href": "https://maps-intern.zaanstad.gem.local/geoserver/wfs?SERVICE=WFS", + "rel": "item", + "title": "geo:kaartboeck", + "type": "OGC:WFS" + }, + { + "href": "https://maps-intern.zaanstad.gem.local/geoserver/wfs?SERVICE=WFS&version=1.0.0&request=GetFeature&typeName=geo:kaartboeck&outputFormat=csv", + "rel": "item", + "type": "download" + }, + { + "href": "https://maps-intern.zaanstad.gem.local/geoserver/wfs?SERVICE=WFS&version=1.0.0&request=GetFeature&typeName=geo:kaartboeck&outputFormat=shape-zip", + "rel": "item", + "type": "download" + } + ] + }, + "2": { + "id": "ffffffaa-4087-59ec-9ea7-8416f58e99dd", + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/record-core" + ], + "type": "Feature", + "time": { + "interval": [ + null, + null + ] + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.4552947, + 52.3348457 + ], + [ + 4.4552947, + 53.388444 + ], + [ + 7.135964, + 53.388444 + ], + [ + 7.135964, + 52.3348457 + ], + [ + 4.4552947, + 52.3348457 + ] + ] + ] + }, + "properties": { + "created": "2022-06-01Z", + "updated": "2025-06-16T12:39:57Z", + "type": "dataset", + "title": "Diepteligging onderkant keileem (t.o.v. NAP)", + "description": "Diepteligging van de onderkant (basis) van keileem in Drenthe, in meters ten opzichte van NAP.", + "contacts": [ + { + "name": "Team Gis/Cartografie", + "organization": "Provincie Drenthe", + "phones": [ + { + "value": "0592-365555" + } + ], + "emails": [ + { + "value": "post@drenthe.nl" + } + ], + "addresses": [ + { + "deliveryPoint": [ + "Westerbrink 1" + ], + "city": [ + "Assen" + ], + "administrativeArea": [ + "Drenthe" + ], + "postalCode": [ + "9400AC" + ], + "country": [ + "Nederland" + ] + } + ], + "links": [ + { + "href": "https://www.provincie.drenthe.nl", + "rel": null, + "title": null, + "description": null + } + ], + "roles": [ + "pointOfContact" + ] + } + ], + "externalIds": [ + { + "scheme": "default", + "value": "ffffffaa-4087-59ec-9ea7-8416f58e99dd" + } + ], + "themes": [ + { + "concepts": [ + { + "id": "beleidsinstrument" + }, + { + "id": "bodem" + }, + { + "id": "grondwaterstand" + }, + { + "id": "landbouw" + }, + { + "id": "landbouwgrond" + }, + { + "id": "waterhuishouding" + } + ], + "scheme": null + } + ], + "_metadata-anytext": "ffffffaa-4087-59ec-9ea7-8416f58e99dd Team Gis/Cartografie Provincie Drenthe Auteur 0592-365555 0592-365777 Westerbrink 1 Assen Drenthe 9400AC Nederland post@drenthe.nl ISO 19115 Nederlandse metadata profiel op ISO 19115 voor geografie 1.3 28992 EPSG Diepteligging onderkant keileem (t.o.v. NAP) GBI.KEILEEM_DIEPTE_ONDER_NAP_R 3d30357b-1bf0-5ccc-8a40-4b980f2ba493 Diepteligging van de onderkant (basis) van keileem in Drenthe, in meters ten opzichte van NAP. Milieu, energie en klimaat Team Natuur en Water Provincie Drenthe Auteur 0592-365555 0592-365777 Westerbrink 1 Assen Drenthe 9400AC Nederland post@drenthe.nl https://kaartportaal.drenthe.nl/portal/sharing/rest/content/items/d547218a21fd4c7ca666e01e4c33f237/info/thumbnail/thumbnail1651583291057.png thumbnail beleidsinstrument bodem grondwaterstand landbouw landbouwgrond waterhuishouding Trefwoordenlijst Provincie Drenthe dataset niet gebruiken bij een schaal kleiner dan Geen beperkingen http://creativecommons.org/publicdomain/mark/1.0/deed.nl Provincie Drenthe zie rapportage Team Gis/Cartografie Provincie Drenthe Auteur 0592-365555 0592-365777 Westerbrink 1 Assen Drenthe 9400AC Nederland post@drenthe.nl Gratis Neem contact op met Provincie Drenthe OGC:WMS 0 Diepteligging onderkant keileem (t.o.v. NAP) Productieproces en achtergrondinformatie zijn beschreven in de rapportage van TNO (TNO 2013 R10107). zie rapportage" + }, + "links": [ + { + "href": "https://kaartportaal.drenthe.nl/server/services/GDB_actueel/GBI_KEILEEM_DIEPTE_ONDER_NAP_R/MapServer/WMSServer", + "rel": "item", + "title": "0", + "type": "OGC:WMS" + } + ] + }, + "3": { + "id": "59352e7f-3792-4e17-bd73-9bba84a98890", + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/record-core" + ], + "type": "Feature", + "time": { + "interval": [ + null, + null + ] + }, + "geometry": null, + "properties": { + "created": "2021-06-30Z", + "updated": "2025-06-16T12:39:57Z", + "type": "dataset", + "title": "Clusters geluid - wegen gecumuleerd", + "description": "Clusters (omtreklijn) gebaseerd op gemeentegrenzen. Per cluster zijn de aantallen woningen en gevoelige bestemmingen per GES-score geteld. Bij de gevoelige bestemmingen is onderscheid gemaakt in 3 categorien: Ziekenhuizen, Scholen en dagverblijven voor jeugd, Verpleeg en verzorgingshuizen.", + "contacts": [ + { + "name": "GIS", + "organization": "Provincie Utrecht", + "emails": [ + { + "value": "GIS@provincie-utrecht.nl" + } + ], + "links": [ + { + "href": "http://www.provincie-utrecht.nl", + "rel": null, + "title": null, + "description": null + } + ], + "roles": [ + "pointOfContact" + ] + } + ], + "externalIds": [ + { + "scheme": "default", + "value": "59352e7f-3792-4e17-bd73-9bba84a98890" + } + ], + "themes": [ + { + "concepts": [ + { + "id": "GELUIDHINDER" + }, + { + "id": "GELUIDSZONES" + }, + { + "id": "PROVINCIALE WEGEN" + }, + { + "id": "VERKEERSLAWAAI" + }, + { + "id": "WET GELUIDHINDER" + } + ], + "scheme": null + }, + { + "concepts": [ + { + "id": "Informatief" + } + ], + "scheme": null + } + ], + "_metadata-anytext": "59352e7f-3792-4e17-bd73-9bba84a98890 GIS Provincie Utrecht Technisch verantwoordelijk GIS@provincie-utrecht.nl ISO 19115 Nederlands metadata profiel op ISO 19115 voor geografie 2.0.0 https://www.opengis.net/def/crs/EPSG/0/28992 EPSG Clusters geluid - wegen gecumuleerd milieu.mgkp_cluster_gl_wegen_cumulati d5e50c1f-37a3-4687-a146-99c0820f5e8c Clusters (omtreklijn) gebaseerd op gemeentegrenzen. Per cluster zijn de aantallen woningen en gevoelige bestemmingen per GES-score geteld. Bij de gevoelige bestemmingen is onderscheid gemaakt in 3 categorien: Ziekenhuizen, Scholen en dagverblijven voor jeugd, Verpleeg en verzorgingshuizen. milieubelasting koppelen aan gezondheidseffecten zodat het gebruikt kan worden voor andere beleidsvelden oa Europese Richtlijn Omgevingslawaai Bosch, Hans van den Provincie Utrecht Technisch verantwoordelijk GIS@provincie-utrecht.nl Janssen, Geert Provincie Utrecht Inhoudelijk verantwoordelijk gis@provincie-utrecht.nl https://services.geodata-utrecht.nl/geoserver/m01_4_overlast_hinder_mgkp/wms?request=GetMap&service=WMS&SRS=EPSG:28992&CRS=EPSG:28992&bbox=112656.06,436541.64,173834.94,482076.36&width=600&height=446&format=image/png&styles=&layers=Clusters_geluid_-_wegen_gecumuleerd GELUIDHINDER GELUIDSZONES PROVINCIALE WEGEN VERKEERSLAWAAI WET GELUIDHINDER Interprovinciale thesaurus Informatief Typering Geen gebruiksbeperkingen geen beperkingen https://creativecommons.org/publicdomain/mark/1.0/deed.nl SDE Feature Class 10.4.1 GIS Provincie Utrecht Technisch verantwoordelijk GIS@provincie-utrecht.nl landingpage OGC:WFS Clusters_geluid_-_wegen_gecumuleerd OGC:WMS Clusters_geluid_-_wegen_gecumuleerd De clusters zijn de gemeentegrenzen. Per gemeente zijn de aantallen belaste woningen en gevoelige bestemmingen per GES klasse bepaald." + }, + "links": [ + { + "href": "https://download.geodata-utrecht.nl/download/vector/59352e7f-3792-4e17-bd73-9bba84a98890", + "rel": "item", + "type": "landingpage" + }, + { + "href": "https://services.geodata-utrecht.nl/geoserver/m01_4_overlast_hinder_mgkp/wfs", + "rel": "item", + "title": "Clusters_geluid_-_wegen_gecumuleerd", + "type": "OGC:WFS" + }, + { + "href": "https://services.geodata-utrecht.nl/geoserver/m01_4_overlast_hinder_mgkp/wms", + "rel": "item", + "title": "Clusters_geluid_-_wegen_gecumuleerd", + "type": "OGC:WMS" + } + ] + } + } +} diff --git a/tests/provider/test_tinydb_catalogue_provider.py b/tests/provider/test_tinydb_catalogue_provider.py index b3f8f3346..92cecf22b 100644 --- a/tests/provider/test_tinydb_catalogue_provider.py +++ b/tests/provider/test_tinydb_catalogue_provider.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -38,6 +38,7 @@ from ..util import get_test_file_path path = get_test_file_path('tests/data/open.canada.ca/sample-records.tinydb') +path2 = get_test_file_path('tests/data/dutch-nationaalgeoregister/sample-records.tinydb') # noqa @pytest.fixture() @@ -90,6 +91,19 @@ def config(tmp_path): } +@pytest.fixture() +def config2(tmp_path): + tmp_file = tmp_path / 'sample-records.tinydb' + shutil.copy(path2, tmp_file) + return { + 'name': 'TinyDBCatalogue', + 'type': 'feature', + 'data': tmp_file, + 'id_field': 'externalId', + 'time_field': 'created' + } + + def test_domains(config): p = TinyDBCatalogueProvider(config) @@ -173,6 +187,12 @@ def test_query(config): assert results['features'][0]['id'] == '8a09413a-0a01-4aab-8925-720d987deb20' # noqa +def test_query_no_geom(config2): + p = TinyDBCatalogueProvider(config2) + results = p.query(bbox=[-180, -90, 180, 90]) + assert len(results['features']) == 2 + + def test_get(config): p = TinyDBCatalogueProvider(config) From 402a79243a27a31bffe8ba8228bd26370eabb081 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Fri, 16 Jan 2026 05:26:19 -0500 Subject: [PATCH 02/41] fix test --- tests/provider/test_filesystem_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/provider/test_filesystem_provider.py b/tests/provider/test_filesystem_provider.py index 6e70efa1d..824c23eb9 100644 --- a/tests/provider/test_filesystem_provider.py +++ b/tests/provider/test_filesystem_provider.py @@ -54,7 +54,7 @@ def test_query(config): r = p.get_data_path(baseurl, urlpath, dirpath) - assert len(r['links']) == 12 + assert len(r['links']) == 13 r = p.get_data_path(baseurl, urlpath, '/poi_portugal') From 734dabec51380a2aa75a5faf33ba1c1b7fe1a9db Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 20 Jan 2026 18:21:46 -0500 Subject: [PATCH 03/41] update EDR and Maps to support MetOcean access and visualization workflows (#2213) (#2214) * update EDR and Maps to support MetOcean access and visualization workflows (#2213) * add EDR plugin documentation (#2162) --- docs/source/configuration.rst | 6 ++ docs/source/plugins.rst | 62 +++++++++-- docs/source/publishing/ogcapi-coverages.rst | 10 ++ docs/source/publishing/ogcapi-edr.rst | 7 +- docs/source/publishing/ogcapi-maps.rst | 4 + pygeoapi/api/__init__.py | 100 ++++++++++++++---- pygeoapi/api/coverages.py | 18 +++- pygeoapi/api/environmental_data_retrieval.py | 26 ++++- pygeoapi/api/itemtypes.py | 20 +--- pygeoapi/api/maps.py | 71 +++++++++++-- pygeoapi/openapi.py | 19 +++- pygeoapi/provider/base_edr.py | 13 ++- pygeoapi/provider/wms_facade.py | 4 +- pygeoapi/provider/xarray_.py | 13 ++- .../schemas/config/pygeoapi-config-0.x.yml | 25 ++++- tests/api/test_api.py | 23 ++++ .../api/test_environmental_data_retrieval.py | 3 +- tests/api/test_maps.py | 19 +++- tests/pygeoapi-test-config.yml | 5 + 19 files changed, 374 insertions(+), 74 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 11c39255b..35ea9f20f 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -225,6 +225,12 @@ default. begin: 2000-10-30T18:24:39Z # start datetime in RFC3339 end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS + # additional extents can be added as desired (1..n) + foo: + url: https://example.org/def # required URL of the extent + range: [0, 10] # required overall range/extent + units: °C # optional units + values: [0, 2, 5, 5, 10] # optional, enumeration of values providers: # list of 1..n required connections information - type: feature # underlying data geospatial type. Allowed values are: feature, coverage, record, tile, edr name: CSV # required: plugin name or import path. See Plugins section for more information. diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 4d02699ca..4d7e52e59 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -30,10 +30,15 @@ The core pygeoapi plugin registry can be found in ``pygeoapi.plugin.PLUGINS``. Each plugin type implements its relevant base class as the API contract: -* data providers: ``pygeoapi.provider.base`` -* output formats: ``pygeoapi.formatter.base`` -* processes: ``pygeoapi.process.base`` -* process_manager: ``pygeoapi.process.manager.base`` +* data providers: + + * features/records/maps: ``pygeoapi.provider.base.BaseProvider`` + * edr: ``pygeoapi.provider.base_edr.BaseEDRProvider`` + * tiles: ``pygeoapi.provider.tile.BaseTileProvider`` + +* output formats: ``pygeoapi.formatter.base.BaseFormatter`` +* processes: ``pygeoapi.process.base.BaseProcessor`` +* process_manager: ``pygeoapi.process.manager.base.BaseManager`` .. todo:: link PLUGINS to API doc @@ -150,7 +155,7 @@ option 2 above). Example: custom pygeoapi vector data provider --------------------------------------------- -Lets consider the steps for a vector data provider plugin: +Let's consider the steps for a vector data provider plugin: Python code ^^^^^^^^^^^ @@ -223,7 +228,7 @@ Each base class documents the functions, arguments and return types required for Example: custom pygeoapi raster data provider --------------------------------------------- -Lets consider the steps for a raster data provider plugin: +Let's consider the steps for a raster data provider plugin: Python code ^^^^^^^^^^^ @@ -278,6 +283,51 @@ Each base class documents the functions, arguments and return types required for .. _example-custom-pygeoapi-processing-plugin: +Example: custom pygeoapi EDR data provider +------------------------------------------ + +Let's consider the steps for an EDR data provider plugin: + +Python code +^^^^^^^^^^^ + +The below template provides a minimal example (let's call the file ``mycooledrdata.py``: + +.. code-block:: python + + from pygeoapi.provider.base_edr import BaseEDRProvider + + class MyCoolEDRDataProvider(BaseEDRProvider): + + def __init__(self, provider_def): + """Inherit from the parent class""" + + super().__init__(provider_def) + + self.covjson = {...} + + def get_instances(self): + return ['foo', 'bar'] + + def get_instance(self, instance): + return instance in get_instances() + + def position(self, **kwargs): + return self.covjson + + def trajectory(self, **kwargs): + return self.covjson + + +For brevity, the ``position`` function returns ``self.covjson`` which is a +dictionary of a CoverageJSON representation. ``get_instances`` returns a list +of instances associated with the collection/plugin, and ``get_instance`` returns +a boolean of whether a given instance exists/is valid. EDR query types are subject +to the query functions defined in the plugin. In the example above, the plugin +implements ``position`` and ``trajectory`` queries, which will be advertised as +supported query types. + + Example: custom pygeoapi processing plugin ------------------------------------------ diff --git a/docs/source/publishing/ogcapi-coverages.rst b/docs/source/publishing/ogcapi-coverages.rst index f277f7c5b..d2016b22f 100644 --- a/docs/source/publishing/ogcapi-coverages.rst +++ b/docs/source/publishing/ogcapi-coverages.rst @@ -89,11 +89,20 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data. format: name: zarr mimetype: application/zip + options: + zarr: + consolidated: true + squeeze: true + .. note:: `Zarr`_ files are directories with files and subdirectories. Therefore a zip file is returned upon request for said format. +.. note:: + + ``options.zarr`` is a custom property that can be used to set `Zarr-specific open options`_. + .. note:: When referencing `NetCDF`_ or `Zarr`_ data stored in an S3 bucket, be sure to provide the full S3 URL. Any parameters required to open the dataset @@ -155,3 +164,4 @@ Data access examples .. _`Zarr`: https://zarr.readthedocs.io/en/stable .. _`GDAL raster driver short name`: https://gdal.org/drivers/raster/index.html .. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input +.. _`Zarr-specific open options`: https://docs.xarray.dev/en/stable/generated/xarray.open_zarr.html diff --git a/docs/source/publishing/ogcapi-edr.rst b/docs/source/publishing/ogcapi-edr.rst index edd205d17..faf7fb37d 100644 --- a/docs/source/publishing/ogcapi-edr.rst +++ b/docs/source/publishing/ogcapi-edr.rst @@ -90,11 +90,15 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data a zip file is returned upon request for said format. .. note:: + + ``options.zarr`` is a custom property that can be used to set `Zarr-specific open options`_. + +.. note:: + When referencing data stored in an S3 bucket, be sure to provide the full S3 URL. Any parameters required to open the dataset using fsspec can be added to the config file under `options` and `s3`, as shown above. - SensorThingsEDR ^^^^^^^^^^^^^^^ @@ -143,3 +147,4 @@ Data access examples .. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF .. _`Zarr`: https://zarr.readthedocs.io/en/stable .. _`OGC Environmental Data Retrieval (EDR) (API)`: https://ogcapi.ogc.org/edr +.. _`Zarr-specific open options`: https://docs.xarray.dev/en/stable/generated/xarray.open_zarr.html diff --git a/docs/source/publishing/ogcapi-maps.rst b/docs/source/publishing/ogcapi-maps.rst index 679e445c9..6924e3a39 100644 --- a/docs/source/publishing/ogcapi-maps.rst +++ b/docs/source/publishing/ogcapi-maps.rst @@ -136,5 +136,9 @@ Data visualization examples * http://localhost:5000/collections/foo/map?bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F3857&bbox=4.022369384765626%2C50.690447870569436%2C4.681549072265626%2C51.00260125274477&width=800&height=600&transparent +* map with vertical subset (``extents.vertical`` must be set in resource level config) + + * http://localhost:5000/collections/foo/map?bbox=-142,42,-52,84&subset=vertical(435) + .. _`OGC API - Maps`: https://ogcapi.ogc.org/maps .. _`see website`: https://mapserver.org/mapscript/index.html diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 6b5468674..b3247f8a7 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -7,7 +7,7 @@ # Colin Blackburn # Ricardo Garcia Silva # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -588,6 +588,7 @@ def get_exception(self, status: int, headers: dict, format_: str | None, """ exception_info = sys.exc_info() + LOGGER.error( description, exc_info=exception_info if exception_info[0] is not None else None @@ -709,22 +710,22 @@ def landing_page(api: API, 'title': l10n.translate('Collections', request.locale), 'href': api.get_collections_url() }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/processes', + 'rel': f'{OGC_RELTYPES_BASE}/processes', 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('Processes', request.locale), 'href': f"{api.base_url}/processes" }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list', + 'rel': f'{OGC_RELTYPES_BASE}/job-list', 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('Jobs', request.locale), 'href': f"{api.base_url}/jobs" }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes', + 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('The list of supported tiling schemes as JSON', request.locale), # noqa 'href': f"{api.base_url}/TileMatrixSets?f=json" }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes', + 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 'type': FORMAT_TYPES[F_HTML], 'title': l10n.translate('The list of supported tiling schemes as HTML', request.locale), # noqa 'href': f"{api.base_url}/TileMatrixSets?f=html" @@ -897,7 +898,10 @@ def describe_collections(api: API, request: APIRequest, 'links': [] } - bbox = v['extents']['spatial']['bbox'] + extents = deepcopy(v['extents']) + + bbox = extents['spatial']['bbox'] + LOGGER.debug('Setting spatial extents from configuration') # The output should be an array of bbox, so if the user only # provided a single bbox, wrap it in a array. if not isinstance(bbox[0], list): @@ -907,12 +911,13 @@ def describe_collections(api: API, request: APIRequest, 'bbox': bbox } } - if 'crs' in v['extents']['spatial']: + if 'crs' in extents['spatial']: collection['extent']['spatial']['crs'] = \ - v['extents']['spatial']['crs'] + extents['spatial']['crs'] - t_ext = v.get('extents', {}).get('temporal', {}) + t_ext = extents.get('temporal', {}) if t_ext: + LOGGER.debug('Setting temporal extents from configuration') begins = dategetter('begin', t_ext) ends = dategetter('end', t_ext) collection['extent']['temporal'] = { @@ -921,6 +926,24 @@ def describe_collections(api: API, request: APIRequest, if 'trs' in t_ext: collection['extent']['temporal']['trs'] = t_ext['trs'] + _ = extents.pop('spatial', None) + _ = extents.pop('temporal', None) + + for ek, ev in extents.items(): + LOGGER.debug(f'Adding extent {ek}') + collection['extent'][ek] = { + 'definition': ev['url'], + 'interval': [ev['range']] + } + if 'units' in ev: + collection['extent'][ek]['unit'] = ev['units'] + + if 'values' in ev: + collection['extent'][ek]['grid'] = { + 'cellsCount': len(ev['values']), + 'coordinates': ev['values'] + } + LOGGER.debug('Processing configured collection links') for link in l10n.translate(v.get('links', []), request.locale): lnk = { @@ -990,13 +1013,13 @@ def describe_collections(api: API, request: APIRequest, if collection_data_type == 'record': collection['links'].append({ 'type': FORMAT_TYPES[F_JSON], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/ogc-catalog', + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', 'title': l10n.translate('Record catalogue as JSON', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}' }) collection['links'].append({ 'type': FORMAT_TYPES[F_HTML], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/ogc-catalog', + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', 'title': l10n.translate('Record catalogue as HTML', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}' }) @@ -1021,13 +1044,13 @@ def describe_collections(api: API, request: APIRequest, LOGGER.debug('Adding feature/record based links') collection['links'].append({ 'type': 'application/schema+json', - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables', + 'rel': f'{OGC_RELTYPES_BASE}/queryables', 'title': l10n.translate('Queryables for this collection as JSON', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_JSON}' # noqa }) collection['links'].append({ 'type': FORMAT_TYPES[F_HTML], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables', + 'rel': f'{OGC_RELTYPES_BASE}/queryables', 'title': l10n.translate('Queryables for this collection as HTML', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_HTML}' # noqa }) @@ -1135,19 +1158,20 @@ def describe_collections(api: API, request: APIRequest, LOGGER.debug('Adding tile links') collection['links'].append({ 'type': FORMAT_TYPES[F_JSON], - 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', 'title': l10n.translate('Tiles as JSON', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' # noqa + 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' }) collection['links'].append({ 'type': FORMAT_TYPES[F_HTML], - 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', 'title': l10n.translate('Tiles as HTML', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' # noqa + 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' }) try: map_ = get_provider_by_type(v['providers'], 'map') + p = load_plugin('provider', map_) except ProviderTypeError: map_ = None @@ -1158,15 +1182,36 @@ def describe_collections(api: API, request: APIRequest, map_format = map_['format']['name'] title_ = l10n.translate('Map as', request.locale) - title_ = f"{title_} {map_format}" + title_ = f'{title_} {map_format}' collection['links'].append({ 'type': map_mimetype, - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/map', + 'rel': f'{OGC_RELTYPES_BASE}/map', 'title': title_, - 'href': f"{api.get_collections_url()}/{k}/map?f={map_format}" # noqa + 'href': f'{api.get_collections_url()}/{k}/map?f={map_format}' }) + if p._fields: + schema_reltype = f'{OGC_RELTYPES_BASE}/schema', + schema_links = [s for s in collection['links'] if + schema_reltype in s] + + if not schema_links: + title_ = l10n.translate('Schema of collection in JSON', request.locale) # noqa + collection['links'].append({ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{k}/schema?f=json' # noqa + }) + title_ = l10n.translate('Schema of collection in HTML', request.locale) # noqa + collection['links'].append({ + 'type': 'text/html', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{k}/schema?f=html' # noqa + }) + try: edr = get_provider_by_type(v['providers'], 'edr') p = load_plugin('provider', edr) @@ -1217,6 +1262,10 @@ def describe_collections(api: API, request: APIRequest, } } } + + if request.format is not None and request.format == 'json': + data_query['link']['type'] = 'application/vnd.cov+json' + collection['data_queries'][qt] = data_query title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa @@ -1334,9 +1383,14 @@ def get_collection_schema(api: API, request: Union[APIRequest, Any], p = load_plugin('provider', get_provider_by_type( api.config['resources'][dataset]['providers'], 'coverage')) # noqa except ProviderTypeError: - LOGGER.debug('Loading record provider') - p = load_plugin('provider', get_provider_by_type( - api.config['resources'][dataset]['providers'], 'record')) + try: + LOGGER.debug('Loading record provider') + p = load_plugin('provider', get_provider_by_type( + api.config['resources'][dataset]['providers'], 'record')) + except ProviderTypeError: + LOGGER.debug('Loading edr provider') + p = load_plugin('provider', get_provider_by_type( + api.config['resources'][dataset]['providers'], 'edr')) except ProviderGenericError as err: LOGGER.error(err) return api.get_exception( diff --git a/pygeoapi/api/coverages.py b/pygeoapi/api/coverages.py index 38b66ef7c..b44a15fce 100644 --- a/pygeoapi/api/coverages.py +++ b/pygeoapi/api/coverages.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -37,12 +37,13 @@ # # ================================================================= - +from copy import deepcopy import logging from http import HTTPStatus from typing import Tuple from pygeoapi import l10n +from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError from pygeoapi.util import ( @@ -216,8 +217,8 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, for k, v in get_visible_collections(cfg).items(): try: - load_plugin('provider', get_provider_by_type( - collections[k]['providers'], 'coverage')) + p = load_plugin('provider', get_provider_by_type( + collections[k]['providers'], 'coverage')) except ProviderTypeError: LOGGER.debug('collection is not coverage based') continue @@ -226,6 +227,11 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, title = l10n.translate(v['title'], locale) description = l10n.translate(v['description'], locale) + parameters = get_oas_30_parameters(cfg, locale) + + coll_properties = deepcopy(parameters)['properties'] + coll_properties['schema']['items']['enum'] = list(p.fields.keys()) + paths[coverage_path] = { 'get': { 'summary': f'Get {title} coverage', @@ -236,7 +242,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, {'$ref': '#/components/parameters/lang'}, {'$ref': '#/components/parameters/f'}, {'$ref': '#/components/parameters/bbox'}, - {'$ref': '#/components/parameters/bbox-crs'} + {'$ref': '#/components/parameters/bbox-crs'}, + {'$ref': f"{OPENAPI_YAML['oacov']}#/components/parameters/subset"}, # noqa + coll_properties ], 'responses': { '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 96eacb426..853559156 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -113,13 +113,14 @@ def get_collection_edr_instances(api: API, request: APIRequest, if instance_id is not None: try: - instances = [p.get_instance(instance_id)] + if p.get_instance(instance_id): + instances = [instance_id] except ProviderItemNotFoundError: msg = 'Instance not found' return api.get_exception( HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) else: - instances = p.instances() + instances = p.get_instances() for instance in instances: instance_dict = { @@ -149,13 +150,18 @@ def get_collection_edr_instances(api: API, request: APIRequest, for qt in p.get_query_types(): if qt == 'instances': continue + data_query = { 'link': { - 'href': f'{uri}/instances/{instance}/{qt}', + 'href': f'{uri}/instances/{instance}/{qt}?f={request.format}', # noqa 'rel': 'data', 'title': f'{qt} query' } } + + if request.format is not None and request.format == 'json': + data_query['link']['type'] = 'application/vnd.cov+json' + instance_dict['data_queries'][qt] = data_query data['instances'].append(instance_dict) @@ -369,6 +375,15 @@ def get_collection_edr_query(api: API, request: APIRequest, within = request.params.get('within') within_units = request.params.get('within-units') + corridor_width = width_units = None + corridor_height = height_units = None + if query_type == 'corridor': + LOGGER.debug('Processing corridor width / height / units parameters') + corridor_width = request.params.get('corridor-width') + width_units = request.params.get('width-units') + corridor_height = request.params.get('corridor-height') + height_units = request.params.get('height-units') + LOGGER.debug('Processing z parameter') try: z = get_typed_value(request.params.get('z')) @@ -408,6 +423,10 @@ def get_collection_edr_query(api: API, request: APIRequest, bbox=bbox, within=within, within_units=within_units, + corridor_width=corridor_width, + width_units=width_units, + corridor_height=corridor_height, + height_units=height_units, limit=limit, location_id=location_id, crs_transform_spec=crs_transform_spec @@ -481,6 +500,7 @@ def get_collection_edr_query(api: API, request: APIRequest, headers['Content-Disposition'] = cd else: + headers['Content-Type'] = 'application/vnd.cov+json' content = to_json(data, api.pretty_print) return headers, HTTPStatus.OK, content diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 20936f759..ca561b9b2 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -1024,21 +1024,6 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections - properties = { - 'name': 'properties', - 'in': 'query', - 'description': 'The properties that should be included for each feature. The parameter value is a comma-separated list of property names.', # noqa - 'required': False, - 'style': 'form', - 'explode': False, - 'schema': { - 'type': 'array', - 'items': { - 'type': 'string' - } - } - } - limit = { 'name': 'limit', 'in': 'query', @@ -1093,8 +1078,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, title = l10n.translate(v['title'], locale) description = l10n.translate(v['description'], locale) - coll_properties = deepcopy(properties) + oas_30_parameters = get_oas_30_parameters(cfg, locale) + coll_properties = deepcopy(oas_30_parameters)['properties'] coll_properties['schema']['items']['enum'] = list(p.fields.keys()) coll_limit = _derive_limit( @@ -1103,7 +1089,7 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, ) dataset_formatters = get_dataset_formatters(v) - coll_f_parameter = deepcopy(get_oas_30_parameters(cfg, locale))['f'] # noqa + coll_f_parameter = deepcopy(oas_30_parameters)['f'] for key, value in dataset_formatters.items(): coll_f_parameter['schema']['enum'].append(value.f) diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index d8df6d354..888073938 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -46,13 +46,17 @@ from pygeoapi.crs import transform_bbox from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin -from pygeoapi.provider.base import ProviderGenericError +from pygeoapi.provider.base import ( + ProviderGenericError, ProviderInvalidDataError +) from pygeoapi.util import ( get_provider_by_type, to_json, filter_providers_by_type, filter_dict_by_key_value ) -from . import APIRequest, API, validate_datetime +from . import ( + APIRequest, API, F_JSON, FORMAT_TYPES, validate_datetime, validate_subset +) LOGGER = logging.getLogger(__name__) @@ -68,7 +72,7 @@ def get_collection_map(api: API, request: APIRequest, dataset: str, style: str | None = None ) -> Tuple[dict, int, str]: """ - Returns a subset of a collection map + Returns an image of a collection map :param request: A request object :param dataset: dataset name @@ -167,10 +171,58 @@ def get_collection_map(api: API, request: APIRequest, HTTPStatus.BAD_REQUEST, headers, request.format, 'InvalidParameterValue', msg) + if 'subset' in request.params: + # TODO get subsets from provider + subsets = deepcopy(api.config['resources'][dataset]['extents']) + subsets.pop('spatial', None) # bbox + subsets.pop('temporal', None) # datetime + LOGGER.debug('Processing subset parameter') + try: + query_args['subsets'] = validate_subset( + request.params['subset'] or '') + except (AttributeError, ValueError) as err: + msg = f'Invalid subset: {err}' + return api.get_exception( + HTTPStatus.BAD_REQUEST, headers, format_, + 'InvalidParameterValue', msg) + + for sk in query_args['subsets'].keys(): + if sk not in subsets.keys(): + msg = f'Subset not found; valid values are {subsets}' + return api.get_exception( + HTTPStatus.BAD_REQUEST, headers, format_, + 'InvalidParameterValue', msg) + + if request.params.get('properties'): + try: + fields = p.get_fields() or {} + except NotImplementedError: + msg = 'No properties implemented' + headers['Content-Type'] = FORMAT_TYPES[F_JSON] + return api.get_exception( + HTTPStatus.NOT_IMPLEMENTED, headers, format_, + 'InvalidParameterValue', msg) + + LOGGER.debug('Processing properties parameter') + properties = request.params.get('properties') or [] + if isinstance(properties, str): + properties = properties.split(',') + + if properties and not any((fld in properties) + for fld in fields.keys()): + msg = f'Invalid property; valid property names are {list(fields.keys())}' # noqa + headers['Content-Type'] = FORMAT_TYPES[F_JSON] + return api.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'InvalidParameterValue', msg) + + query_args['select_properties'] = properties + LOGGER.debug('Generating map') try: data = p.query(**query_args) - except ProviderGenericError as err: + except (ProviderGenericError, ProviderInvalidDataError) as err: + headers['Content-Type'] = FORMAT_TYPES[F_JSON] return api.get_exception( err.http_status_code, headers, request.format, err.ogc_exception_code, err.message) @@ -197,7 +249,7 @@ def get_collection_map_legend(api: API, request: APIRequest, dataset: str, style: str | None = None ) -> Tuple[dict, int, str]: """ - Returns a subset of a collection map legend + Returns an image of a collection map legend :param request: A request object :param dataset: dataset name @@ -279,6 +331,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, if map_extension: mp = load_plugin('provider', map_extension) + coll_properties = deepcopy(parameters)['properties'] + coll_properties['schema']['items']['enum'] = list(mp.fields.keys()) + map_f = deepcopy(parameters['f']) map_f['schema']['enum'] = [map_extension['format']['name']] map_f['schema']['default'] = map_extension['format']['name'] @@ -293,6 +348,7 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, 'parameters': [ {'$ref': '#/components/parameters/bbox'}, {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa + {'$ref': f"{OPENAPI_YAML['oamaps']}#/components/parameters/subset"}, # noqa { 'name': 'width', 'in': 'query', @@ -342,6 +398,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, } } } + if coll_properties['schema']['items']['enum']: + paths[pth]['get']['parameters'].append(coll_properties) + if mp.time_field is not None: paths[pth]['get']['parameters'].append( {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index c3016c828..594218a40 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -4,7 +4,7 @@ # Authors: Francesco Bartoli # Authors: Ricardo Garcia Silva # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2023 Ricardo Garcia Silva # @@ -56,7 +56,8 @@ 'oapif-1': 'https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml', # noqa 'oapif-2': 'https://schemas.opengis.net/ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml', # noqa 'oapip': 'https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi', - 'oacov': 'https://raw.githubusercontent.com/tomkralidis/ogcapi-coverages-1/fix-cis/yaml-unresolved', # noqa + 'oacov': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-coverages/refs/heads/master/standard/openapi/ogcapi-coverages-1.yaml', # noqa + 'oamaps': 'https://schemas.opengis.net/ogcapi/maps/part1/1.0/openapi/ogcapi-maps-1.yaml', # noqa 'oapir': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi', # noqa 'oaedr': 'https://schemas.opengis.net/ogcapi/edr/1.0/openapi', # noqa 'oapit': 'https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml', # noqa @@ -666,6 +667,20 @@ def get_oas_30_parameters(cfg: dict, locale_: str): 'style': 'form', 'explode': False }, + 'properties': { + 'name': 'properties', + 'in': 'query', + 'description': 'The properties that should be included. The parameter value is a comma-separated list of property names.', # noqa + 'required': False, + 'style': 'form', + 'explode': False, + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string' + } + } + }, 'vendorSpecificParameters': { 'name': 'vendorSpecificParameters', 'in': 'query', diff --git a/pygeoapi/provider/base_edr.py b/pygeoapi/provider/base_edr.py index 69669fe6e..01b1602b6 100644 --- a/pygeoapi/provider/base_edr.py +++ b/pygeoapi/provider/base_edr.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2021 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -55,8 +55,6 @@ def __init__(self, provider_def): BaseProvider.__init__(self, provider_def) -# self.instances = [] - def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) @@ -80,6 +78,15 @@ def __init_subclass__(cls, **kwargs): 'but requests will be routed to a feature provider' ) + def get_instances(self): + """ + Get a list of instance identifiers + + :returns: `list` of instance identifiers + """ + + return NotImplementedError() + def get_instance(self, instance): """ Validate instance identifier diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index b4f2495f1..e96f3c244 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -66,7 +66,7 @@ def __init__(self, provider_def): def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, height=300, crs=4326, datetime_=None, transparent=True, - bbox_crs=4326, format_='png'): + bbox_crs=4326, format_='png', **kwargs): """ Generate map diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 6dbd9060f..4038c5b8c 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -4,7 +4,7 @@ # Authors: Tom Kralidis # # Copyright (c) 2020 Gregory Petrochenkov -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -61,9 +61,14 @@ def __init__(self, provider_def): super().__init__(provider_def) + open_options = {} + squeeze = provider_def.get('options', {}).get('squeeze', False) + zarr_options = provider_def.get('options', {}).get('zarr', {}) + try: if provider_def['data'].endswith('.zarr'): open_func = xarray.open_zarr + open_options = zarr_options else: if '*' in self.data: LOGGER.debug('Detected multi file dataset') @@ -84,7 +89,7 @@ def __init__(self, provider_def): data_to_open = self.data try: - self._data = open_func(data_to_open) + self._data = open_func(data_to_open, **open_options) except ValueError as err: # Manage non-cf-compliant time dimensions if 'time' in str(err): @@ -92,6 +97,10 @@ def __init__(self, provider_def): else: raise err + if squeeze: + LOGGER.debug('Squeezing data') + self._data = self._data.squeeze() + if provider_def.get('storage_crs') is None: self.storage_crs = self._parse_storage_crs() diff --git a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml index f48f615ff..8f0ae873e 100644 --- a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml @@ -415,7 +415,7 @@ properties: - href extents: type: object - description: spatial and temporal extents + description: spatial and temporal extents. Note that adding custom named dimensions is also possible properties: spatial: type: object @@ -450,6 +450,28 @@ properties: type: string description: temporal reference system of features default: 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' + patternProperties: + "^(?!spatial$|temporal$).*": + type: object + description: additional custom dimensions + properties: + range: + type: array + description: The overall range of the dimension + minItems: 2 + url: + type: string + format: uri + description: A URI to a description of the dimension + units: + type: string + description: Units of the dimension + values: + type: array + description: enumerated list of values + required: + - url + - range required: - spatial limits: @@ -567,7 +589,6 @@ properties: http://www.opengis.net/def/crs/OGC/1.3/CRS84 storage_crs_coordinate_epoch: type: number - format: uri description: |- point in time at which coordinates in the spatial feature collection are referenced to the dynamic coordinate reference system in `storageCrs`, that may be used to retrieve features from a diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 3625eac94..0f8e785c1 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -592,6 +592,7 @@ def test_describe_collections(config, api_): assert collection['title'] == 'Observations' assert collection['description'] == 'My cool observations' assert len(collection['links']) == 15 + assert collection['extent'] == { 'spatial': { 'bbox': [[-180, -90, 180, 90]], @@ -656,6 +657,28 @@ def test_describe_collections(config, api_): assert collection['storageCrs'] == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' # noqa assert collection['storageCrsCoordinateEpoch'] == 2017.23 + # test custom extents + rsp_headers, code, response = describe_collections( + api_, req, 'mapserver_world_map') + + collection = json.loads(response) + + assert collection['extent'] == { + 'spatial': { + 'bbox': [[-180, -90, 180, 90]], + 'crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + }, + 'custom-extent': { + 'definition': 'https://example.org/custom-extent', + 'interval': [[0, 10]], + 'unit': '°C', + 'grid': { + 'cellsCount': 3, + 'coordinates': [0, 5, 10] + } + } + } + def test_describe_collections_hidden_resources( config_hidden_resources, api_hidden_resources): diff --git a/tests/api/test_environmental_data_retrieval.py b/tests/api/test_environmental_data_retrieval.py index 8b7d665e1..8d028a7c2 100644 --- a/tests/api/test_environmental_data_retrieval.py +++ b/tests/api/test_environmental_data_retrieval.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # # Permission is hereby granted, free of charge, to any person @@ -81,6 +81,7 @@ def test_get_collection_edr_query(config, api_): rsp_headers, code, response = get_collection_edr_query( api_, req, 'icoads-sst', None, 'position') assert code == HTTPStatus.OK + assert rsp_headers['Content-Type'] == 'application/vnd.cov+json' data = json.loads(response) diff --git a/tests/api/test_maps.py b/tests/api/test_maps.py index fb043dfeb..b13228d2f 100644 --- a/tests/api/test_maps.py +++ b/tests/api/test_maps.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # # Permission is hereby granted, free of charge, to any person @@ -51,6 +51,18 @@ def test_get_collection_map(config, api_): assert isinstance(response, bytes) assert response[1:4] == b'PNG' + req = mock_api_request({'subset': 'foo("bar")'}) + rsp_headers, code, response = get_collection_map( + api_, req, 'mapserver_world_map') + + assert code == HTTPStatus.BAD_REQUEST + + req = mock_api_request({'properties': 'foo,bar'}) + rsp_headers, code, response = get_collection_map( + api_, req, 'mapserver_world_map') + + assert code == HTTPStatus.NOT_IMPLEMENTED + def test_map_crs_transform(config, api_): # Florida in EPSG:4326 @@ -58,9 +70,11 @@ def test_map_crs_transform(config, api_): 'bbox': '-88.374023,24.826625,-78.112793,31.015279', # crs is 4326 by implicit since it is the default } + req = mock_api_request(params) _, code, floridaIn4326 = get_collection_map( api_, req, 'mapserver_world_map') + assert code == HTTPStatus.OK # Area that isn't florida in the ocean; used to make sure @@ -73,6 +87,7 @@ def test_map_crs_transform(config, api_): req = mock_api_request(params) _, code, florida4326InWrongCRS = get_collection_map( api_, req, 'mapserver_world_map') + assert code == HTTPStatus.OK assert florida4326InWrongCRS != floridaIn4326 @@ -82,8 +97,10 @@ def test_map_crs_transform(config, api_): 'bbox': '-9837751.2884,2854464.3843,-8695476.3377,3634733.5690', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/3857' } + req = mock_api_request(params) _, code, floridaProjectedIn3857 = get_collection_map( api_, req, 'mapserver_world_map') + assert code == HTTPStatus.OK assert floridaIn4326 == floridaProjectedIn3857 diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 6efe0312f..13dc63aa9 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -387,6 +387,11 @@ resources: spatial: bbox: [-180,-90,180,90] crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + custom-extent: + url: https://example.org/custom-extent + units: °C + range: [0, 10] + values: [0, 5, 10] providers: - type: map name: WMSFacade From 00815491170949baf8f964de505989be86fcec3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Cant=C3=B9?= Date: Thu, 22 Jan 2026 14:20:28 +0100 Subject: [PATCH 04/41] Fix admin with empty resources (#2208) * Fix admin with empty resources * Update test_openapi.py * fix flake8 --------- Co-authored-by: Tom Kralidis --- pygeoapi/openapi.py | 28 ++++- tests/other/test_openapi.py | 13 +++ ...oapi-test-config-admin-empty-resources.yml | 103 ++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 tests/pygeoapi-test-config-admin-empty-resources.yml diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index 594218a40..759335781 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -731,10 +731,32 @@ def get_admin(cfg: dict) -> dict: schema_dict = get_config_schema() paths = {} - - res_eg_key = next(iter(cfg['resources'])) + if cfg['resources']: + res_eg_key = next(iter(cfg['resources'])) + else: + res_eg_key = 'example' res_eg = { res_eg_key: cfg['resources'][res_eg_key] + } if cfg['resources'] else { + 'example': { + 'type': 'collection', + 'title': 'Example', + 'description': 'Example', + 'keywords': ['example'], + 'links': [], + 'linked-data': {}, + 'extents': { + 'spatial': { + 'bbox': [-180, -90, 180, 90], + 'crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + }, + 'temporal': { + 'begin': '2000-10-30T18:24:39Z', + 'end': '2007-10-30T08:57:29Z', + 'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' # noqa + } + } + } } if 'extents' in res_eg[res_eg_key]: res_eg_eg_key = 'extents' @@ -839,7 +861,7 @@ def get_admin(cfg: dict) -> dict: 'description': 'Adds resource to configuration', 'content': { 'application/json': { - 'example': {'new-collection': cfg['resources'][res_eg_key]}, # noqa + 'example': {'new-collection': cfg['resources'][res_eg_key] if cfg['resources'] else res_eg['example'] }, # noqa 'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa } }, diff --git a/tests/other/test_openapi.py b/tests/other/test_openapi.py index 49f4f70ba..0832ec433 100644 --- a/tests/other/test_openapi.py +++ b/tests/other/test_openapi.py @@ -43,6 +43,14 @@ def config(): return yaml_load(fh) +@pytest.fixture() +def config_admin_empty_resources(): + with open( + get_test_file_path('pygeoapi-test-config-admin-empty-resources.yml') + ) as fh: + return yaml_load(fh) + + @pytest.fixture() def config_hidden_resources(): filename = 'pygeoapi-test-config-hidden-resources.yml' @@ -131,3 +139,8 @@ def test_hidden_resources(config_hidden_resources): assert '/collections/obs' not in openapi_doc['paths'] assert '/collections/obs/items' not in openapi_doc['paths'] + + +def test_admin_empty_resources(config_admin_empty_resources): + openapi_doc = get_oas(config_admin_empty_resources) + assert '/admin/config' in openapi_doc['paths'] diff --git a/tests/pygeoapi-test-config-admin-empty-resources.yml b/tests/pygeoapi-test-config-admin-empty-resources.yml new file mode 100644 index 000000000..202022ad4 --- /dev/null +++ b/tests/pygeoapi-test-config-admin-empty-resources.yml @@ -0,0 +1,103 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2019 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + admin: true + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: {} From 498245870eb9736e84289f23d3994a36a5ebc5d6 Mon Sep 17 00:00:00 2001 From: Prince Mahar <74172630+Prinxeeee@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:13:21 +0530 Subject: [PATCH 05/41] Docs: fix typos and clarify get_srid docstring (#2219) --- pygeoapi/crs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index efeab27fa..0c2ff7b48 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -70,7 +70,7 @@ class CrsTransformSpec: def get_srid(crs: Union[str, pyproj.CRS]) -> Union[int, None]: """ - Helper function to attempt to exctract an ESPG SRID from + Helper function to attempt to extract an EPSG SRID from a `pyproj.CRS` object. :param crs: `pyproj.CRS` object From 4a8b8ea7451a3916b3abf8a937257d757649d12c Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Wed, 28 Jan 2026 13:36:48 -0500 Subject: [PATCH 06/41] pin gunicorn (#2221) (#2222) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b9992faea..fb17435e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -133,7 +133,7 @@ ADD . /pygeoapi RUN python3 -m venv --system-site-packages /venv \ && /venv/bin/python3 -m pip install --no-cache-dir -r requirements-docker.txt \ && /venv/bin/python3 -m pip install --no-cache-dir -r requirements-admin.txt \ - && /venv/bin/python3 -m pip install --no-cache-dir gunicorn \ + && /venv/bin/python3 -m pip install --no-cache-dir "gunicorn<24" \ && /venv/bin/python3 -m pip install --no-cache-dir -e . # Set default config and entrypoint for Docker Image From 99446d5f4b4502d7788edad4ef536c98561eb5fe Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:22:42 -0700 Subject: [PATCH 07/41] Update ESRI Provider (#2225) - Allow for alternate id_field - Do native CRS conversion - Safe handle empty responses on get_fields --- pygeoapi/provider/esri.py | 107 ++++++++++++++++++++++----- tests/provider/test_esri_provider.py | 26 ++++++- 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/pygeoapi/provider/esri.py b/pygeoapi/provider/esri.py index 85cc69d9e..c84b6e1f1 100644 --- a/pygeoapi/provider/esri.py +++ b/pygeoapi/provider/esri.py @@ -32,7 +32,7 @@ import logging from requests import Session, codes -from pygeoapi.crs import crs_transform, get_srid +from pygeoapi.crs import get_srid from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderTypeError, ProviderQueryError) from pygeoapi.util import format_datetime @@ -60,7 +60,7 @@ def __init__(self, provider_def): super().__init__(provider_def) self.url = f'{self.data}/query' - self.crs = get_srid(self.storage_crs) + self.srid = get_srid(self.storage_crs) self.username = provider_def.get('username') self.password = provider_def.get('password') self.token_url = provider_def.get('token_service', ARCGIS_URL) @@ -68,6 +68,11 @@ def __init__(self, provider_def): self.token = None self.session = Session() + self.using_deafult_id = any( + kw == self.id_field + for kw in ['OBJECTID', 'objectid', 'fid'] + ) + self.login() self.get_fields() @@ -80,13 +85,17 @@ def get_fields(self): if not self._fields: # Load fields - params = {'f': 'pjson'} - resp = self.get_response(self.data, params=params) + try: + resp = self.get_response(self.data, params={'f': 'pjson'}) + except ProviderConnectionError as err: + msg = f'Could not access resource {self.data}: {err}' + LOGGER.error(msg) + return {} if resp.get('error') is not None: msg = f"Connection error: {resp['error']['message']}" LOGGER.error(msg) - raise ProviderConnectionError(msg) + return {} try: # Verify Feature/Map Service supports required capabilities @@ -108,10 +117,10 @@ def get_fields(self): return self._fields - @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None, **kwargs): + select_properties=[], skip_geometry=False, + crs_transform_spec=None, **kwargs): """ ESRI query @@ -124,7 +133,7 @@ def query(self, offset=0, limit=10, resulttype='results', :param sortby: list of dicts (property, order) :param select_properties: list of property names :param skip_geometry: bool of whether to skip geometry (default False) - :param q: full-text search term(s) + :param crs_transform_spec: `CrsTransformSpec` instance, optional :returns: `dict` of GeoJSON FeatureCollection """ @@ -133,7 +142,7 @@ def query(self, offset=0, limit=10, resulttype='results', params = { 'f': 'geoJSON', - 'outSR': self.crs, + 'outSR': self._get_srid(crs_transform_spec), 'outFields': self._make_fields(select_properties), 'where': self._make_where(properties, datetime_) } @@ -166,12 +175,12 @@ def query(self, offset=0, limit=10, resulttype='results', return fc - @crs_transform - def get(self, identifier, **kwargs): + def get(self, identifier, crs_transform_spec=None, **kwargs): """ Query ESRI by id :param identifier: feature id + :param crs_transform_spec: `CrsTransformSpec` instance, optional :returns: dict of single GeoJSON feature """ @@ -179,17 +188,28 @@ def get(self, identifier, **kwargs): LOGGER.debug(f'Fetching item: {identifier}') params = { 'f': 'geoJSON', - 'outSR': self.crs, - 'objectIds': identifier, + 'outSR': self._get_srid(crs_transform_spec), 'outFields': self._make_fields() } - resp = self.get_response(self.url, params=params) + if self.using_deafult_id: + params['objectIds'] = identifier + else: + params['where'] = self._make_where( + [(self.id_field, identifier)] + ) + LOGGER.debug('Returning item') - return resp['features'].pop() + [feature] = self._make_features( + self.get_response(params=params) + ) + + return feature def login(self): - # Generate token from username and password + """ + Generate login token from username and password + """ if self.token is None: if None in [self.username, self.password]: @@ -211,7 +231,17 @@ def login(self): 'X-Esri-Authorization': f'Bearer {self.token}' }) - def get_response(self, url, **kwargs): + def get_response(self, url: str = None, **kwargs): + """ + Get response from ESRI service + + :param url: `str` of ESRI service URL if not using default + + :returns: `dict` of ESRI response + """ + if url is None: + url = self.url + # Form URL for GET request LOGGER.debug('Sending query') with self.session.get(url, **kwargs) as r: @@ -314,9 +344,22 @@ def _get_count(self, params): params['returnCountOnly'] = 'true' params['f'] = 'pjson' - response = self.get_response(self.url, params=params) + response = self.get_response(params=params) return response.get('count', 0) + def _get_srid(self, crs_transform_spec): + """ + Get SRID from CrsTransformSpec + + :param crs_transform_spec: `CrsTransformSpec` instance + + :returns: `int` of SRID + """ + if crs_transform_spec is not None: + return get_srid(crs_transform_spec.target_crs) + + return self.srid + def _get_all(self, params, hits_): """ Get all features from query args @@ -329,7 +372,9 @@ def _get_all(self, params, hits_): params = deepcopy(params) # Return feature collection - features = self.get_response(self.url, params=params).get('features') + features = self._make_features( + self.get_response(params=params) + ) step = len(features) # Query if values are less than expected @@ -338,7 +383,9 @@ def _get_all(self, params, hits_): params['resultOffset'] += step params['resultRecordCount'] += step - fs = self.get_response(self.url, params=params).get('features') + fs = self._make_features( + self.get_response(params=params) + ) if len(fs) != 0: features.extend(fs) else: @@ -346,7 +393,27 @@ def _get_all(self, params, hits_): return features + def _make_features(self, feature_collection: dict = {}): + """ + Make a feature from features list + + :param features: `dict` of features + + :returns: `dict` of single feature + """ + features = feature_collection.get('features', []) + + for feature in features: + if not self.using_deafult_id: + feature['id'] = \ + feature['properties'][self.id_field] + + return features + def __exit__(self, **kwargs): + """ + Exit and close session + """ self.session.close() def __repr__(self): diff --git a/tests/provider/test_esri_provider.py b/tests/provider/test_esri_provider.py index b90b6cf1e..65870de79 100644 --- a/tests/provider/test_esri_provider.py +++ b/tests/provider/test_esri_provider.py @@ -35,20 +35,34 @@ TIME_FIELD = 'Date_Time' +BASE_URL = 'https://sampleserver6.arcgisonline.com/arcgis/rest/services' + @pytest.fixture() def config(): - # National Hurricane Center () + # National Hurricane Center # source: ESRI, NOAA/National Weather Service return { 'name': 'ESRI', 'type': 'feature', - 'data': 'https://sampleserver6.arcgisonline.com/arcgis/rest/services/Hurricanes/MapServer/0', # noqa + 'data': f'{BASE_URL}/Hurricanes/MapServer/0', 'id_field': 'OBJECTID', 'time_field': TIME_FIELD } +@pytest.fixture() +def config_alt_id(): + # Emergency Facilities + # source: ESRI + return { + 'name': 'ESRI', + 'type': 'feature', + 'data': f'{BASE_URL}/EmergencyFacilities/FeatureServer/0', + 'id_field': 'facilityid' + } + + def test_query(config): p = ESRIServiceProvider(config) @@ -179,3 +193,11 @@ def test_get(config): result = p.get(6) assert result['id'] == 6 assert result['properties']['EVENTID'] == 'Alberto' + + +def test_alternative_id_field(config_alt_id): + p = ESRIServiceProvider(config_alt_id) + + result = p.get('F0234') + assert result['id'] == 'F0234' + assert result['properties']['facname'] == 'Redlands Community Hospital' From 41011f1ce5d5868e737492f625b24522eb8ebd5d Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:35:08 -0700 Subject: [PATCH 08/41] Cleanup xarray provider (#2224) * Cleanup xarray provider * Respond to PR feedback * Fix xarray tests --- pygeoapi/provider/xarray_.py | 54 +++++++++++------- pygeoapi/provider/xarray_edr.py | 63 +++++++++++---------- tests/provider/test_xarray_zarr_provider.py | 6 +- 3 files changed, 70 insertions(+), 53 deletions(-) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 4038c5b8c..36353243a 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -126,6 +126,11 @@ def get_fields(self): elif dtype.name.startswith('str'): dtype = 'string' + if value.attrs.get('units') is None: + msg = f'Field {key} missing units, will be skipped' + LOGGER.warning(msg) + continue + self._fields[key] = { 'type': dtype, 'title': value.attrs.get('long_name'), @@ -249,19 +254,21 @@ def query(self, properties=[], subsets={}, bbox=[], bbox_crs=4326, data.coords[self.x_field].values[-1], data.coords[self.y_field].values[-1] ], - "driver": "xarray", - "height": data.sizes[self.y_field], - "width": data.sizes[self.x_field], - "variables": {var_name: var.attrs - for var_name, var in data.variables.items()} + 'driver': 'xarray', + 'height': data.sizes[self.y_field], + 'width': data.sizes[self.x_field], + 'variables': { + var_name: var.attrs + for var_name, var in data.variables.items() + } } if self.time_field is not None: out_meta['time'] = [ _to_datetime_string(data.coords[self.time_field].values[0]), - _to_datetime_string(data.coords[self.time_field].values[-1]), + _to_datetime_string(data.coords[self.time_field].values[-1]) ] - out_meta["time_steps"] = data.sizes[self.time_field] + out_meta['time_steps'] = data.sizes[self.time_field] LOGGER.debug('Serializing data in memory') if format_ == 'json': @@ -395,25 +402,30 @@ def gen_covjson(self, metadata, data, fields): try: for key, value in selected_fields.items(): LOGGER.debug(f'Adding range {key}') - cj['ranges'][key] = { + range = { 'type': 'NdArray', 'dataType': value['type'], 'axisNames': [ 'y', 'x' ], - 'shape': [metadata['height'], - metadata['width']] + 'shape': [ + metadata['height'], metadata['width'] + ], + 'values': [ + None if np.isnan(v) else v + for v in data[key].values.flatten() + ] } - cj['ranges'][key]['values'] = [ - None if np.isnan(v) else v - for v in data[key].values.flatten() - ] if self.time_field is not None: - cj['ranges'][key]['axisNames'].append('t') - cj['ranges'][key]['shape'].append(metadata['time_steps']) + LOGGER.debug(f'Adding time axis to range {key}') + range['axisNames'].insert(0, 't') + range['shape'].insert(0, metadata['time_steps']) + + cj['ranges'][key] = range + except IndexError as err: - LOGGER.warning(err) + LOGGER.error(err) raise ProviderQueryError('Invalid query parameter') LOGGER.debug('Returning data') @@ -684,11 +696,11 @@ def _get_zarr_data(data): def _convert_float32_to_float64(data): """ - Converts DataArray values of float32 to float64 - :param data: Xarray dataset of coverage data + Converts DataArray values of float32 to float64 + :param data: Xarray dataset of coverage data - :returns: Xarray dataset of coverage data - """ + :returns: Xarray dataset of coverage data + """ for var_name in data.variables: if data[var_name].dtype == 'float32': diff --git a/pygeoapi/provider/xarray_edr.py b/pygeoapi/provider/xarray_edr.py index a2403b014..c7af0c5c4 100644 --- a/pygeoapi/provider/xarray_edr.py +++ b/pygeoapi/provider/xarray_edr.py @@ -36,7 +36,7 @@ from pygeoapi.provider.xarray_ import ( _to_datetime_string, _convert_float32_to_float64, - XarrayProvider, + XarrayProvider ) LOGGER = logging.getLogger(__name__) @@ -73,10 +73,9 @@ def position(self, **kwargs): query_params = {} + LOGGER.debug('Query type: position') LOGGER.debug(f'Query parameters: {kwargs}') - LOGGER.debug(f"Query type: {kwargs.get('query_type')}") - wkt = kwargs.get('wkt') if wkt is not None: LOGGER.debug('Processing WKT') @@ -115,7 +114,10 @@ def position(self, **kwargs): try: if select_properties: - self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa + self._fields = { + k: v for k, v in self._fields.items() + if k in select_properties + } data = self._data[[*select_properties]] else: data = self._data @@ -156,12 +158,12 @@ def position(self, **kwargs): bbox = wkt.bounds out_meta = { 'bbox': [bbox[0], bbox[1], bbox[2], bbox[3]], - "time": time, - "driver": "xarray", - "height": height, - "width": width, - "time_steps": time_steps, - "variables": {var_name: var.attrs + 'time': time, + 'driver': 'xarray', + 'height': height, + 'width': width, + 'time_steps': time_steps, + 'variables': {var_name: var.attrs for var_name, var in data.variables.items()} } @@ -183,12 +185,11 @@ def cube(self, **kwargs): query_params = {} + LOGGER.debug('Query type: cube') LOGGER.debug(f'Query parameters: {kwargs}') - LOGGER.debug(f"Query type: {kwargs.get('query_type')}") - bbox = kwargs.get('bbox') - xmin, ymin, xmax, ymax = self._configure_bbox(bbox) + xmin, ymin, xmax, ymax = self._configure_bbox() if len(bbox) == 4: query_params[self.x_field] = slice(bbox[xmin], bbox[xmax]) @@ -208,15 +209,17 @@ def cube(self, **kwargs): if datetime_ is not None: query_params[self.time_field] = self._make_datetime(datetime_) + fields = { + field: self.fields[field] + for field in select_properties + if field in self.fields + } if select_properties else self.fields + LOGGER.debug(f'query parameters: {query_params}') try: - if select_properties: - self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa - data = self._data[[*select_properties]] - else: - data = self._data - data = data.sel(query_params) - data = _convert_float32_to_float64(data) + data = _convert_float32_to_float64( + self._data[[*fields]].sel(query_params) + ) except KeyError: raise ProviderNoDataError() @@ -231,16 +234,18 @@ def cube(self, **kwargs): data.coords[self.x_field].values[-1], data.coords[self.y_field].values[-1] ], - "time": time, - "driver": "xarray", - "height": height, - "width": width, - "time_steps": time_steps, - "variables": {var_name: var.attrs - for var_name, var in data.variables.items()} + 'time': time, + 'driver': 'xarray', + 'height': height, + 'width': width, + 'time_steps': time_steps, + 'variables': { + var_name: var.attrs + for var_name, var in data.variables.items() + } } - return self.gen_covjson(out_meta, data, self.fields) + return self.gen_covjson(out_meta, data, fields) def _make_datetime(self, datetime_): """ @@ -300,7 +305,7 @@ def _parse_time_metadata(self, data, kwargs): time_steps = kwargs.get('limit') return time, time_steps - def _configure_bbox(self, bbox): + def _configure_bbox(self): xmin, ymin, xmax, ymax = 0, 1, 2, 3 if self._data[self.x_field][0] > self._data[self.x_field][-1]: xmin, xmax = xmax, xmin diff --git a/tests/provider/test_xarray_zarr_provider.py b/tests/provider/test_xarray_zarr_provider.py index 9b1bdb660..1b66f6a07 100644 --- a/tests/provider/test_xarray_zarr_provider.py +++ b/tests/provider/test_xarray_zarr_provider.py @@ -73,7 +73,7 @@ def config_no_time(tmp_path): def test_provider(config): p = XarrayProvider(config) - assert len(p.fields) == 4 + assert len(p.fields) == 3 assert len(p.axes) == 3 assert p.axes == ['lon', 'lat', 'time'] @@ -82,7 +82,7 @@ def test_schema(config): p = XarrayProvider(config) assert isinstance(p.fields, dict) - assert len(p.fields) == 4 + assert len(p.fields) == 3 assert p.fields['analysed_sst']['title'] == 'analysed sea surface temperature' # noqa @@ -107,7 +107,7 @@ def test_numpy_json_serial(): def test_no_time(config_no_time): p = XarrayProvider(config_no_time) - assert len(p.fields) == 4 + assert len(p.fields) == 3 assert p.axes == ['lon', 'lat'] coverage = p.query(format='json') From 84f4412f6f7e30cb8f96d7b49b71c37f0f876860 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Wed, 28 Jan 2026 18:53:17 -0500 Subject: [PATCH 09/41] HTML: set clear opacity if collection has a map (#2226) --- pygeoapi/templates/collections/collection.html | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pygeoapi/templates/collections/collection.html b/pygeoapi/templates/collections/collection.html index 27ac770f3..57146a506 100644 --- a/pygeoapi/templates/collections/collection.html +++ b/pygeoapi/templates/collections/collection.html @@ -147,13 +147,6 @@

{% trans %}Storage CRS{% endtrans %}

} )); - {# if this collection has a map representation, add it to the map #} - {% for link in data['links'] %} - {% if link['rel'] == 'http://www.opengis.net/def/rel/ogc/1.0/map' and link['href'] %} - L.imageOverlay.ogcapi("{{ data['base_url'] }}", {collection: "{{ data['id'] }}", "opacity": .7, "transparent": true}).addTo(map); - {% endif %} - {% endfor %} - var bbox_layer = L.polygon([ ['{{ data['extent']['spatial']['bbox'][0][1] }}', '{{ data['extent']['spatial']['bbox'][0][0] }}'], ['{{ data['extent']['spatial']['bbox'][0][3] }}', '{{ data['extent']['spatial']['bbox'][0][0] }}'], @@ -161,6 +154,16 @@

{% trans %}Storage CRS{% endtrans %}

['{{ data['extent']['spatial']['bbox'][0][1] }}', '{{ data['extent']['spatial']['bbox'][0][2] }}'] ]); + {# if this collection has a map representation, add it to the map #} + {% for link in data['links'] %} + {% if link['rel'] == 'http://www.opengis.net/def/rel/ogc/1.0/map' and link['href'] %} + L.imageOverlay.ogcapi("{{ data['base_url'] }}", {collection: "{{ data['id'] }}", "opacity": .7, "transparent": true}).addTo(map); + bbox_layer.setStyle({ + fillOpacity: 0 + }); + {% endif %} + {% endfor %} + map.addLayer(bbox_layer); map.fitBounds(bbox_layer.getBounds(), {maxZoom: 10}); From 6457233ebbf4c2bae2e8011120d4abbdd1316d01 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 29 Jan 2026 11:10:21 -0500 Subject: [PATCH 10/41] add support for OGC API Publish-Subscribe Workflow - Part 1: Core (#2146) (#2220) * add support for OGC API Publish-Subscribe Workflow - Part 1: Core (#2146) * update docs * update docs * fix API deletion on not found items in backend * fix ref * fix docs * add AsyncAPI support * fix tests * add exception handling * update docs --- .github/workflows/main.yml | 1 + docker/entrypoint.sh | 9 + docs/source/administration.rst | 21 +- docs/source/configuration.rst | 18 + docs/source/index.rst | 1 + docs/source/pubsub.rst | 135 + locale/en/LC_MESSAGES/messages.po | 18 + pygeoapi/__init__.py | 5 +- pygeoapi/api/__init__.py | 86 +- pygeoapi/api/itemtypes.py | 33 +- pygeoapi/api/processes.py | 12 +- pygeoapi/api/pubsub.py | 128 + pygeoapi/asyncapi.py | 292 + pygeoapi/django_/urls.py | 3 +- pygeoapi/django_/views.py | 14 +- pygeoapi/flask_app.py | 17 +- pygeoapi/openapi.py | 6 + pygeoapi/plugin.py | 6 +- pygeoapi/provider/base.py | 4 +- pygeoapi/pubsub/__init__.py | 30 + pygeoapi/pubsub/base.py | 107 + pygeoapi/pubsub/http.py | 105 + pygeoapi/pubsub/mqtt.py | 121 + .../schemas/asyncapi/asyncapi-3.0.0.json | 9077 +++++++++++++++++ .../schemas/config/pygeoapi-config-0.x.yml | 27 + pygeoapi/starlette_app.py | 18 +- pygeoapi/templates/asyncapi.html | 24 + pygeoapi/templates/landing_page.html | 36 +- pygeoapi/util.py | 16 +- requirements-pubsub.txt | 1 + tests/api/test_pubsub.py | 106 + tests/conftest.py | 6 + tests/provider/test_postgresql_provider.py | 7 + tests/pygeoapi-test-asyncapi.yml | 56 + tests/pygeoapi-test-config-pubsub.yml | 458 + 35 files changed, 10967 insertions(+), 37 deletions(-) create mode 100644 docs/source/pubsub.rst create mode 100644 pygeoapi/api/pubsub.py create mode 100644 pygeoapi/asyncapi.py create mode 100644 pygeoapi/pubsub/__init__.py create mode 100644 pygeoapi/pubsub/base.py create mode 100644 pygeoapi/pubsub/http.py create mode 100644 pygeoapi/pubsub/mqtt.py create mode 100644 pygeoapi/resources/schemas/asyncapi/asyncapi-3.0.0.json create mode 100644 pygeoapi/templates/asyncapi.html create mode 100644 requirements-pubsub.txt create mode 100644 tests/api/test_pubsub.py create mode 100644 tests/pygeoapi-test-asyncapi.yml create mode 100644 tests/pygeoapi-test-config-pubsub.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2cb8f54fd..179453e8b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -126,6 +126,7 @@ jobs: pip3 install -r requirements-provider.txt pip3 install -r requirements-manager.txt pip3 install -r requirements-django.txt + pip3 install -r requirements-pubsub.txt pip3 install . pip3 install GDAL==`gdal-config --version` - name: setup test data ⚙️ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 91be8913e..ae70a87eb 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -46,6 +46,9 @@ fi if [[ -z "$PYGEOAPI_OPENAPI" ]]; then export PYGEOAPI_OPENAPI="${PYGEOAPI_HOME}/local.openapi.yml" fi +if [[ -z "$PYGEOAPI_ASYNCAPI" ]]; then + export PYGEOAPI_ASYNCAPI="${PYGEOAPI_HOME}/local.asyncapi.yml" +fi # gunicorn env settings with defaults SCRIPT_NAME=${SCRIPT_NAME:=/} @@ -87,6 +90,12 @@ echo "Trying to generate openapi.yml" echo "openapi.yml generated continue to pygeoapi" +echo "Trying to generate asyncapi.yml" +/venv/bin/pygeoapi asyncapi generate ${PYGEOAPI_CONFIG} --output-file ${PYGEOAPI_ASYNCAPI} + +[[ $? -ne 0 ]] && error "asyncapi.yml could not be generated ERROR" +echo "asyncapi.yml generated continue to pygeoapi" + start_gunicorn() { # SCRIPT_NAME should not have value '/' [[ "${SCRIPT_NAME}" = '/' ]] && export SCRIPT_NAME="" && echo "make SCRIPT_NAME empty from /" diff --git a/docs/source/administration.rst b/docs/source/administration.rst index fcebd291b..9fe541f3a 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -24,12 +24,6 @@ To generate the OpenAPI document, run the following: This will dump the OpenAPI document as YAML to your system's ``stdout``. To save to a file on disk, run: -.. code-block:: bash - - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml > /path/to/my-pygeoapi-openapi.yml - -You can also write to a file explicitly via the ``--output-file`` option: - .. code-block:: bash pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --output-file /path/to/my-pygeoapi-openapi.yml @@ -38,7 +32,7 @@ To generate the OpenAPI document as JSON, run: .. code-block:: bash - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml -f json > /path/to/my-pygeoapi-openapi.json + pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-openapi.yml .. note:: Generate as YAML or JSON? If your OpenAPI YAML definition is slow to render as JSON, @@ -83,6 +77,11 @@ In UNIX: # or if OpenAPI JSON export PYGEOAPI_OPENAPI=/path/to/my-pygeoapi-openapi.json + # if your server supports AsyncAPI and Pub/Sub + export PYGEOAPI_ASYNCAPI=/path/to/my-pygeoapi-asyncapi.yml + # or if AsyncAPI JSON + export PYGEOAPI_ASYNCAPI=/path/to/my-pygeoapi-asyncapi.json + In Windows: .. code-block:: bat @@ -92,6 +91,14 @@ In Windows: # or if OpenAPI JSON set PYGEOAPI_OPENAPI=/path/to/my-pygeoapi-openapi.json + # if your server supports AsyncAPI and Pub/Sub + set PYGEOAPI_ASYNCAPI=/path/to/my-pygeoapi-asyncapi.yml + # or if AsyncAPI JSON + set PYGEOAPI_ASYNCAPI=/path/to/my-pygeoapi-asyncapi.json + +.. note:: + + More information on AsyncAPI and Pub/Sub can be found at :ref:`pubsub`. Summary ------- diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 35ea9f20f..8cab94f52 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -14,6 +14,7 @@ file whatever you wish; typical filenames end with ``.yml``. pygeoapi configuration contains the following core sections: - ``server``: server-wide settings +- ``pubsub``: Publish-Subscribe settings (optional) - ``logging``: logging configuration - ``metadata``: server-wide metadata (contact, licensing, etc.) - ``resources``: dataset collections, processes and stac-collections offered by the server @@ -90,6 +91,23 @@ For more information related to API design rules (the ``api_rules`` property in url_prefix: 'v{api_major}' # adds a /v1 prefix to all URL paths version_header: X-API-Version # add a response header of this name with the API version +``pubsub`` +^^^^^^^^^^ + +The ``pubsub`` section provides directives for enabling publication of CloudEvent messaages on item-based transactions + + +.. code-block:: yaml + + pubsub: + name: MQTT + broker: + url: mqtt://localhost:1883 + channel: my/service/topic + +.. seealso:: + :ref:`pubsub` for more information on Publish-Subscribe capabilities + ``logging`` ^^^^^^^^^^^ diff --git a/docs/source/index.rst b/docs/source/index.rst index 4caa2efcc..4579a27b3 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,6 +39,7 @@ reference documentation on all aspects of the project. openapi publishing/index transactions + pubsub admin-api security plugins diff --git a/docs/source/pubsub.rst b/docs/source/pubsub.rst new file mode 100644 index 000000000..da8570f97 --- /dev/null +++ b/docs/source/pubsub.rst @@ -0,0 +1,135 @@ +.. _pubsub: + +Publish-Subscribe integration (Pub/Sub) +======================================= + +pygeoapi supports Publish-Subscribe (Pub/Sub) integration by implementing +the `OGC API Publish-Subscribe Workflow - Part 1: Core`_ (draft) specification. + +Pub/Sub integration can be enabled by defining a broker that pycsw can use to +publish notifications on given topics using CloudEvents (as per the specification). + +When enabled, core functionality of Pub/Sub includes: + +- providing an AsyncAPI document (JSON and HTML) +- providing the following links on the OGC API landing page: + + - the broker link (``rel=hub`` link relation) + - the AsyncAPI JSON link (``rel=service-desc`` link relation and ``type=application/asyncapi+json`` media type) + - the AsyncAPI HTML link (``rel=service-doc`` link relation and ``type=text/html`` media type) + +- sending a notification message on the following events: + + - feature or record transactions (create, replace, update, delete) + - process executions/job creation + +AsyncAPI +-------- + +`AsyncAPI`_ is the event-driven equivalent to :ref:`openapi` + +The official AsyncAPI specification can be found on the `AsyncAPI`_ website. pygeoapi supports AsyncAPI version 3.0.0. + +AsyncAPI is an optional capability in pygeoapi. To enable AsyncAPI, the following steps are required: + +- defining a ``pubsub`` section in configuration (see :ref:`configuration` and :ref:`brokers` for more information) +- generating an AsyncAPI document +- setting the ``PYGEOAPI_ASYNCAPI`` environment variable + +Creating the AsyncAPI document +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The AsyncAPI document is a YAML or JSON configuration which is generated from the pygeoapi configuration, and describes the server information, channels and the message payloads structures. + +To generate the AsyncAPI document, run the following: + +.. code-block:: bash + + pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml + +This will dump the AsyncAPI document as YAML to your system's ``stdout``. To save to a file on disk, run: + +.. code-block:: bash + + pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml --output-file /path/to/my-pygeoapi-asyncapi.yml + +To generate the AsyncAPI document as JSON, run: + +.. code-block:: bash + + pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-asyncapi.json + +.. note:: + Generate as YAML or JSON? If your AsyncAPI YAML definition is slow to render as JSON, + saving as JSON to disk will help with performance at run-time. + +.. note:: + The AsyncAPI document provides detailed information on query parameters, and dataset + property names and their data types. Whenever you make changes to your pygeoapi configuration, + always refresh the accompanying AsyncAPI document. + +Validating the AsyncAPI document +-------------------------------- + +To ensure your AsyncAPI document is valid, pygeoapi provides a validation +utility that can be run as follows: + +.. code-block:: bash + + pygeoapi asyncapi validate /path/to/my-pygeoapi-asyncapi.yml + +.. _brokers: + +Brokers +------- + +The following protocols are supported: + +MQTT +^^^^ + +Example directive: + +.. code-block:: yaml + + pubsub: + name: MQTT + broker: + url: mqtt://localhost:1883 + channel: messages/a/data # optional + hidden: false # default + +HTTP +^^^^ + +Example directive: + +.. code-block:: yaml + + pubsub: + name: HTTP + broker: + url: https://ntfy.sh + channel: messages-a-data # optional + hidden: true # default false + +.. note:: + + For any Pub/Sub endpoints requiring authentication, encode the ``url`` value as follows: + + * ``mqtt://username:password@localhost:1883`` + * ``https://username:password@localhost`` + + As with any section of the pygeoapi configuration, environment variables may be used as needed, for example + to set username/password information in a URL. If ``pubsub.broker.url`` contains authentication, and + ``pubsub.broker.hidden`` is ``false``, the authentication information will be stripped from the URL + before displaying it on the landing page. + +.. note:: + + If a ``channel`` is defined, it is used as a prefix to the relevant OGC API endpoint used. + + If a ``channel`` is not defined, only the relevant OGC API endpoint is used. + +.. _`OGC API Publish-Subscribe Workflow - Part 1: Core`: https://docs.ogc.org/DRAFTS/25-030.html +.. _`AsyncAPI`: https://www.asyncapi.com diff --git a/locale/en/LC_MESSAGES/messages.po b/locale/en/LC_MESSAGES/messages.po index 17a2698e9..34ad8c485 100644 --- a/locale/en/LC_MESSAGES/messages.po +++ b/locale/en/LC_MESSAGES/messages.po @@ -736,3 +736,21 @@ msgstr "" msgid "Instances" msgstr "" + +msgid "Pub/Sub Notifications" +msgstr "" + +msgid "Pub/Sub broker" +msgstr "" + +msgid "Subscribe to notifications from this service" +msgstr "" + +msgid "AsyncAPI Definition" +msgstr "" + +msgid "The AsyncAPI document as HTML" +msgstr "" + +msgid "The AsyncAPI document as JSON" +msgstr "" diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index f62d8c5a2..30f235f00 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -3,7 +3,7 @@ # Authors: Tom Kralidis # Ricardo Garcia Silva # -# Copyright (c) 2021 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2023 Ricardo Garcia Silva # Copyright (c) 2025 Angelos Tzotsos # @@ -38,6 +38,8 @@ from importlib.metadata import entry_points except ImportError: from importlib_metadata import entry_points + +from pygeoapi.asyncapi import asyncapi from pygeoapi.config import config from pygeoapi.openapi import openapi @@ -110,3 +112,4 @@ def serve(ctx, server): cli.add_command(config) cli.add_command(openapi) +cli.add_command(asyncapi) diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index b3247f8a7..ffc0a8810 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -127,7 +127,7 @@ def all_apis() -> dict: """ from . import (coverages, environmental_data_retrieval, itemtypes, maps, - processes, tiles, stac) + processes, pubsub, tiles, stac) return { 'coverage': coverages, @@ -135,6 +135,7 @@ def all_apis() -> dict: 'itemtypes': itemtypes, 'map': maps, 'process': processes, + 'pubsub': pubsub, 'tile': tiles, 'stac': stac } @@ -531,21 +532,25 @@ def get_request_headers(self, headers: dict) -> dict: class API: """API object""" - def __init__(self, config: dict, openapi: dict) -> Self | None: + def __init__(self, config: dict, openapi: dict, + asyncapi: dict = {}) -> Self | None: """ constructor :param config: configuration dict :param openapi: openapi dict + :param asyncapi: asyncapi dict :returns: `pygeoapi.API` instance """ self.config = config self.openapi = openapi + self.asyncapi = asyncapi self.api_headers = get_api_rules(self.config).response_headers self.base_url = get_base_url(self.config) self.prefetcher = UrlPrefetcher() + self.pubsub_client = None CHARSET[0] = config['server'].get('encoding', 'utf-8') if config['server'].get('gzip'): @@ -573,6 +578,10 @@ def __init__(self, config: dict, openapi: dict) -> Self | None: self.manager = get_manager(self.config) LOGGER.info('Process manager plugin loaded') + if self.config.get('pubsub') is not None: + LOGGER.debug('Loading PubSub client') + self.pubsub_client = load_plugin('pubsub', self.config['pubsub']) + def get_exception(self, status: int, headers: dict, format_: str | None, code: str, description: str) -> Tuple[dict, int, str]: """ @@ -731,6 +740,33 @@ def landing_page(api: API, 'href': f"{api.base_url}/TileMatrixSets?f=html" }] + if api.pubsub_client is not None and not api.pubsub_client.hidden: + LOGGER.debug('Adding PubSub broker link') + pubsub_link = { + 'rel': 'hub', + 'type': 'application/json', + 'title': l10n.translate('Pub/Sub broker', request.locale), + 'href': api.pubsub_client.broker_safe_url + } + if api.pubsub_client.channel is not None: + pubsub_link['channel'] = api.pubsub_client.channel + + fcm['links'].append(pubsub_link) + + if api.asyncapi: + fcm['links'].append({ + 'rel': 'service-doc', + 'type': 'text/html', + 'title': l10n.translate('The AsyncAPI definition as HTML', request.locale), # noqa + 'href': f'{api.base_url}/asyncapi?f=html' + }) + fcm['links'].append({ + 'rel': 'service-desc', + 'type': 'application/asyncapi+json', + 'title': l10n.translate('The AsyncAPI definition as JSON', request.locale), # noqa + 'href': f'{api.base_url}/asyncapi?f=json' + }) + headers = request.get_response_headers(**api.api_headers) if request.format == F_HTML: # render @@ -747,6 +783,14 @@ def landing_page(api: API, 'tile'): fcm['tile'] = True + if api.pubsub_client is not None and not api.pubsub_client.hidden: + fcm['pubsub'] = { + 'name': api.pubsub_client.name, + 'url': api.pubsub_client.broker_safe_url, + 'channel': api.pubsub_client.channel, + 'asyncapi': api.asyncapi + } + content = render_j2_template( api.tpl_config, api.config['server']['templates'], 'landing_page.html', fcm, request.locale) @@ -795,6 +839,41 @@ def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: return headers, HTTPStatus.OK, api.openapi +def asyncapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: + """ + Provide AsyncAPI document + + :param request: A request object + + :returns: tuple of headers, status code, content + """ + + headers = request.get_response_headers(**api.api_headers) + + if not api.asyncapi: + msg = 'AsyncAPI not supported/configured' + return api.get_exception( + HTTPStatus.NOT_IMPLEMENTED, headers, request.format, + 'NoApplicableCode', msg) + + if request.format == F_HTML: + template = 'asyncapi.html' + + path = f'{api.base_url}/asyncapi' + data = { + 'asyncapi-document-path': path + } + content = render_j2_template( + api.tpl_config, api.config['server']['templates'], template, data, + request.locale) + + return headers, HTTPStatus.OK, content + + headers['Content-Type'] = 'application/asyncapi+json' + + return headers, HTTPStatus.OK, to_json(api.asyncapi, api.pretty_print) + + def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: """ Provide conformance definition @@ -824,6 +903,9 @@ def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: conformance_list.extend( apis_dict['itemtypes'].CONFORMANCE_CLASSES_RECORDS) + if api.pubsub_client is not None: + conformance_list.extend(apis_dict['pubsub'].CONFORMANCE_CLASSES) + conformance = { 'conformsTo': sorted(list(set(conformance_list))) } diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index ca561b9b2..19306f57e 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -49,6 +49,7 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit +from pygeoapi.api.pubsub import publish_message from pygeoapi.crs import (DEFAULT_CRS, DEFAULT_STORAGE_CRS, create_crs_transform_spec, get_supported_crs_list, modify_pygeofilter, transform_bbox, @@ -58,7 +59,8 @@ from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin, PLUGINS from pygeoapi.provider.base import ( - ProviderGenericError, ProviderTypeError, SchemaType) + ProviderGenericError, ProviderItemNotFoundError, + ProviderTypeError, SchemaType) from pygeoapi.util import (filter_providers_by_type, to_json, filter_dict_by_key_value, str2bool, @@ -750,6 +752,9 @@ def manage_collection_item( collections = filter_dict_by_key_value(api.config['resources'], 'type', 'collection') + http_status = HTTPStatus.OK + payload = None + if dataset not in collections.keys(): msg = 'Collection not found' return api.get_exception( @@ -795,7 +800,8 @@ def manage_collection_item( if action == 'create': LOGGER.debug('Creating item') try: - identifier = p.create(request.data) + payload = request.data + identifier = p.create(payload) except TypeError as err: msg = str(err) return api.get_exception( @@ -808,12 +814,13 @@ def manage_collection_item( headers['Location'] = f'{api.get_collections_url()}/{dataset}/items/{identifier}' # noqa - return headers, HTTPStatus.CREATED, '' + http_status = HTTPStatus.CREATED if action == 'update': LOGGER.debug('Updating item') try: - _ = p.update(identifier, request.data) + payload = request.data + _ = p.update(identifier, payload) except TypeError as err: msg = str(err) return api.get_exception( @@ -824,10 +831,17 @@ def manage_collection_item( err.http_status_code, headers, request.format, err.ogc_exception_code, err.message) - return headers, HTTPStatus.NO_CONTENT, '' + http_status = HTTPStatus.NO_CONTENT if action == 'delete': LOGGER.debug('Deleting item') + try: + _ = p.get(identifier) + except ProviderItemNotFoundError as err: + return api.get_exception( + err.http_status_code, headers, request.format, + err.ogc_exception_code, err.message) + try: _ = p.delete(identifier) except ProviderGenericError as err: @@ -835,7 +849,14 @@ def manage_collection_item( err.http_status_code, headers, request.format, err.ogc_exception_code, err.message) - return headers, HTTPStatus.OK, '' + http_status = HTTPStatus.OK + + if api.pubsub_client is not None: + LOGGER.debug('Publishing message') + publish_message(api.pubsub_client, api.base_url, action, dataset, + identifier, payload) + + return headers, http_status, '' def get_collection_item(api: API, request: APIRequest, diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 39a165ea0..d6a477edf 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -9,7 +9,7 @@ # Bernhard Mallinger # Francesco Martinelli # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -50,6 +50,7 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit +from pygeoapi.api.pubsub import publish_message from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, @@ -531,6 +532,15 @@ def execute_process(api: API, request: APIRequest, 'status': status.value } + if api.pubsub_client is not None: + LOGGER.debug('Publishing message') + try: + publish_message(api.pubsub_client, api.base_url, 'process', + process_id, job_id, response2) + except Exception as err: + msg = f'Could not publish message {err}' + LOGGER.warning(msg) + return headers, http_status, response2 diff --git a/pygeoapi/api/pubsub.py b/pygeoapi/api/pubsub.py new file mode 100644 index 000000000..fdc528e1e --- /dev/null +++ b/pygeoapi/api/pubsub.py @@ -0,0 +1,128 @@ +# ================================================================= + +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from datetime import datetime, UTC +import json +import logging +import uuid +from typing import Union + +LOGGER = logging.getLogger(__name__) + +CONFORMANCE_CLASSES = [ + 'https://www.opengis.net/spec/ogcapi-pubsub-1/1.0/conf/message-payload-cloudevents-json', # noqa + 'https://www.opengis.net/spec/ogcapi-pubsub-1/1.0/conf/discovery' +] + + +def publish_message(pubsub_client, url: str, action: str, + resource: str = None, item: str = None, + data: dict = None) -> bool: + """ + Publish broker message + + :param pubsub_client: `pygeoapi.pubsub.BasePubSubClient` instance + :param url: `str` of server base URL + :param action: `str` of action trigger name (create, update, delete) + :param resource: `str` of resource identifier + :param item: `str` of item identifier + :param data: `dict` of data payload + + :returns: `bool` of whether message publishing was successful + """ + + if action in ['create', 'update']: + channel = f'collections/{resource}' + data_ = data + media_type = 'application/geo+json' + type_ = f'org.ogc.api.collection.item.{action}' + elif action == 'delete': + channel = f'collections/{resource}' + data_ = item + media_type = 'text/plain' + type_ = f'org.ogc.api.collection.item.{action}' + elif action == 'process': + channel = f'processes/{resource}' + media_type = 'application/json' + data_ = data + type_ = 'org.ogc.api.job.result' + + if pubsub_client.channel is not None: + channel = f'{pubsub_client.channel}/{channel}' + + message = generate_ogc_cloudevent(type_, media_type, url, + channel, data_) + LOGGER.debug(f'Message: {message}') + + try: + pubsub_client.connect() + pubsub_client.pub(channel, json.dumps(message)) + except Exception as err: + raise RuntimeError(err) + + +def generate_ogc_cloudevent(type_: str, media_type: str, source: str, + subject: str, data: Union[dict, str]) -> dict: + """ + Generate CloudEvent + + :param type_: `str` of CloudEvents type + :param source: `str` of source + :param subject: `str` of subject + :param media_type: `str` of media type + :param data: `str` or `dict` of data + + :returns: `dict` of OGC CloudEvent payload + """ + + try: + data2 = json.loads(data) + except Exception: + if isinstance(data, bytes): + data2 = data.decode('utf-8') + else: + data2 = data + + message = { + 'specversion': '1.0', + 'type': type_, + 'source': source, + 'subject': subject, + 'id': str(uuid.uuid4()), + 'time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'datacontenttype': media_type, + # 'dataschema': 'TODO', + 'data': data2 + } + + return message + + +def get_oas_30(cfg, locale_): + return [], {} diff --git a/pygeoapi/asyncapi.py b/pygeoapi/asyncapi.py new file mode 100644 index 000000000..363ffa610 --- /dev/null +++ b/pygeoapi/asyncapi.py @@ -0,0 +1,292 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import os +import json +import logging +from pathlib import Path +from urllib.parse import urlparse + +import click +from jsonschema import validate as jsonschema_validate +import yaml + +from pygeoapi import __version__, l10n +from pygeoapi.models.openapi import OAPIFormat +from pygeoapi.util import to_json, yaml_load, remove_url_auth + +LOGGER = logging.getLogger(__name__) + +THISDIR = os.path.dirname(os.path.realpath(__file__)) + + +def gen_asyncapi(cfg: dict) -> dict: + """ + Generate an AsyncAPI document + + :param cfg: `dict` of pygeoapi configuration + + :returns: `dict` of AsyncAPI document + """ + + server_locales = l10n.get_locales(cfg) + locale_ = server_locales[0] + + LOGGER.debug('Generating AsyncAPI document') + + title = l10n.translate(cfg['metadata']['identification']['title'], locale_) # noqa + description = l10n.translate(cfg['metadata']['identification']['description'], locale_) # noqa + tags = l10n.translate(cfg['metadata']['identification']['keywords'], locale_) # noqa + + u = cfg['pubsub']['broker']['url'] + up = urlparse(u) + protocol = up.scheme + url = remove_url_auth(u).replace(f'{protocol}://', '') + + a = { + 'asyncapi': '3.0.0', + 'id': cfg['server']['url'], + 'defaultContentType': 'application/json', + 'info': { + 'version': __version__, + 'title': title, + 'description': description, + 'license': { + 'name': cfg['metadata']['license']['name'], + 'url': cfg['metadata']['license']['url'] + }, + 'contact': { + 'name': cfg['metadata']['contact']['name'], + 'email': cfg['metadata']['contact']['email'] + }, + 'tags': [{'name': tag} for tag in tags], + 'externalDocs': { + 'url': cfg['metadata']['identification']['url'] + }, + }, + 'servers': { + 'default': { + 'host': url, + 'protocol': protocol, + 'description': description + } + }, + 'channels': {}, + 'operations': {} + } + if cfg['metadata']['contact']['url'].startswith('http'): + a['info']['contact']['url'] = cfg['metadata']['contact']['url'] + + if cfg['pubsub']['broker'].get('channel') is not None: + channel_prefix = cfg['pubsub']['broker']['channel'] + else: + channel_prefix = '' + + LOGGER.debug('Generating channels foreach collection') + for key, value in cfg['resources'].items(): + if value['type'] not in ['collection']: + LOGGER.debug('Skipping') + continue + + title = l10n.translate(value['title'], locale_) + channel_address = f'{channel_prefix}/collections/{key}' + + channel = { + 'description': title, + 'address': channel_address, + 'messages': { + 'DefaultMessage': { + 'payload': { + '$ref': 'https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml' # noqa + } + } + } + } + + operation = { + f'publish-{key}': { + 'action': 'send', + 'channel': { + '$ref': f'#/channels/notify-{key}' + } + }, + f'consume-{key}': { + 'action': 'receive', + 'channel': { + '$ref': f'#/channels/notify-{key}' + } + } + } + + a['channels'][f'notify-{key}'] = channel + a['operations'].update(operation) + + return a + + +def get_asyncapi(cfg, version='3.0'): + """ + Stub to generate AsyncAPI Document + + :param cfg: configuration object + :param version: version of AsyncAPI (default 3.0) + + :returns: AsyncAPI definition YAML dict + """ + + if version == '3.0': + return gen_asyncapi(cfg) + else: + raise RuntimeError('AsyncAPI version not supported') + + +def validate_asyncapi_document(instance_dict): + """ + Validate an AsyncAPI document against the AsyncAPI schema + + :param instance_dict: dict of AsyncAPI instance + + :returns: `bool` of validation + """ + + schema_file = os.path.join( + THISDIR, 'resources', 'schemas', 'asyncapi', 'asyncapi-3.0.0.json') + + LOGGER.debug(f'Validating against {schema_file}') + with open(schema_file) as fh2: + schema_dict = json.load(fh2) + jsonschema_validate(instance_dict, schema_dict) + + return True + + +def generate_asyncapi_document(cfg: dict, output_format: OAPIFormat): + """ + Generate an AsyncAPI document from the configuration file + + :param cfg: `dict` of configuration + :param output_format: output format for AsyncAPI document + + :returns: content of the AsyncAPI document in the output + format requested + """ + + pretty_print = cfg['server'].get('pretty_print', False) + + if output_format == 'yaml': + content = yaml.safe_dump(get_asyncapi(cfg), default_flow_style=False) + else: + content = to_json(get_asyncapi(cfg), pretty=pretty_print) + return content + + +def load_asyncapi_document() -> dict: + """ + Open AsyncAPI document from `PYGEOAPI_ASYNCAPI` environment variable + + :returns: `dict` of AsyncAPI document + """ + + pygeoapi_asyncapi = os.environ.get('PYGEOAPI_ASYNCAPI') + + if pygeoapi_asyncapi is None: + LOGGER.debug('PYGEOAPI_ASYNCAPI environment not set') + return {} + + if not os.path.exists(pygeoapi_asyncapi): + msg = (f'AsyncAPI document {pygeoapi_asyncapi} does not exist. ' + 'Please generate before starting pygeoapi') + LOGGER.error(msg) + raise RuntimeError(msg) + + with open(pygeoapi_asyncapi, encoding='utf8') as ff: + if pygeoapi_asyncapi.endswith(('.yaml', '.yml')): + asyncapi_ = yaml_load(ff) + else: # JSON string, do not transform + asyncapi_ = ff.read() + + return asyncapi_ + + +@click.group() +def asyncapi(): + """AsyncAPI management""" + pass + + +@click.command() +@click.pass_context +@click.argument('config_file', type=click.File(encoding='utf-8')) +@click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']), + default='yaml', help='output format (json|yaml)') +@click.option('--output-file', '-of', type=click.File('w', encoding='utf-8'), + help='Name of output file') +def generate(ctx, config_file, output_file, format_='yaml'): + """Generate AsyncAPI Document""" + + if config_file is None: + raise click.ClickException('--config/-c required') + + if isinstance(config_file, Path): + with config_file.open(mode='r') as cf: + cfg = yaml_load(cf) + else: + cfg = yaml_load(config_file) + + if 'pubsub' not in cfg: + click.echo('pubsub not configured; aborting') + ctx.exit(1) + + content = generate_asyncapi_document(cfg, format_) + + if output_file is None: + click.echo(content) + else: + click.echo(f'Generating {output_file.name}') + output_file.write(content) + click.echo('Done') + + +@click.command() +@click.pass_context +@click.argument('asyncapi_file', type=click.File()) +def validate(ctx, asyncapi_file): + """Validate AsyncAPI Document""" + + if asyncapi_file is None: + raise click.ClickException('--asyncapi/-o required') + + click.echo(f'Validating {asyncapi_file.name}') + instance = yaml_load(asyncapi_file) + validate_asyncapi_document(instance) + click.echo('Valid AsyncAPI document') + + +asyncapi.add_command(generate) +asyncapi.add_command(validate) diff --git a/pygeoapi/django_/urls.py b/pygeoapi/django_/urls.py index 37a19d9ef..108705d08 100644 --- a/pygeoapi/django_/urls.py +++ b/pygeoapi/django_/urls.py @@ -8,7 +8,7 @@ # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 Luca Delucchi # Copyright (c) 2022 Krishna Lodha -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -71,6 +71,7 @@ def apply_slash_rule(url: str): urlpatterns = [ path('', views.landing_page, name='landing-page'), path(apply_slash_rule('openapi/'), views.openapi, name='openapi'), + path(apply_slash_rule('asyncapi/'), views.asyncapi, name='asyncapi'), path( apply_slash_rule('conformance/'), views.conformance, diff --git a/pygeoapi/django_/views.py b/pygeoapi/django_/views.py index 620581f11..976d4236d 100644 --- a/pygeoapi/django_/views.py +++ b/pygeoapi/django_/views.py @@ -8,7 +8,7 @@ # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 Luca Delucchi # Copyright (c) 2022 Krishna Lodha -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -79,6 +79,18 @@ def openapi(request: HttpRequest) -> HttpResponse: return execute_from_django(core_api.openapi_, request) +def asyncapi(request: HttpRequest) -> HttpResponse: + """ + AsyncAPI endpoint + + :request Django HTTP Request + + :returns: Django HTTP Response + """ + + return execute_from_django(core_api.asyncapi_, request) + + def conformance(request: HttpRequest) -> HttpResponse: """ OGC API conformance endpoint diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index f659655fc..1c5676af0 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -3,7 +3,7 @@ # Authors: Tom Kralidis # Norman Barker # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -46,6 +46,7 @@ import pygeoapi.api.processes as processes_api import pygeoapi.api.stac as stac_api import pygeoapi.api.tiles as tiles_api +from pygeoapi.asyncapi import load_asyncapi_document from pygeoapi.openapi import load_openapi_document from pygeoapi.config import get_config from pygeoapi.util import get_mimetype, get_api_rules @@ -53,6 +54,7 @@ CONFIG = get_config() OPENAPI = load_openapi_document() +ASYNCAPI = load_asyncapi_document() API_RULES = get_api_rules(CONFIG) @@ -91,7 +93,7 @@ APP.config['JSONIFY_PRETTYPRINT_REGULAR'] = CONFIG['server'].get( 'pretty_print', True) -api_ = API(CONFIG, OPENAPI) +api_ = API(CONFIG, OPENAPI, ASYNCAPI) OGC_SCHEMAS_LOCATION = CONFIG['server'].get('ogc_schemas_location') @@ -181,6 +183,17 @@ def openapi(): return execute_from_flask(core_api.openapi_, request) +@BLUEPRINT.route('/asyncapi') +def asyncapi(): + """ + AsyncAPI endpoint + + :returns: HTTP response + """ + + return execute_from_flask(core_api.asyncapi_, request) + + @BLUEPRINT.route('/conformance') def conformance(): """ diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index 759335781..09cfee333 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -538,6 +538,11 @@ def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict: try: sub_tags, sub_paths = api_module.get_oas_30(cfg, locale_) + + if not sub_tags and not sub_paths: + LOGGER.debug('Empty content from {api_name}; skipping') + continue + oas['paths'].update(sub_paths['paths']) oas['tags'].extend(sub_tags) except Exception as err: @@ -994,6 +999,7 @@ def validate_openapi_document(instance_dict: dict) -> bool: schema_file = SCHEMASDIR / 'openapi' / 'openapi-3.0.x.json' + LOGGER.debug(f'Validating against {schema_file}') with schema_file.open() as fh2: schema_dict = json.load(fh2) jsonschema_validate(instance_dict, schema_dict) diff --git a/pygeoapi/plugin.py b/pygeoapi/plugin.py index 54f71be0e..19795be15 100644 --- a/pygeoapi/plugin.py +++ b/pygeoapi/plugin.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -85,6 +85,10 @@ 'MongoDB': 'pygeoapi.process.manager.mongodb_.MongoDBManager', 'TinyDB': 'pygeoapi.process.manager.tinydb_.TinyDBManager', 'PostgreSQL': 'pygeoapi.process.manager.postgresql.PostgreSQLManager' + }, + 'pubsub': { + 'HTTP': 'pygeoapi.pubsub.http.HTTPPubSubClient', + 'MQTT': 'pygeoapi.pubsub.mqtt.MQTTPubSubClient' } } diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py index 0c58d90cd..b1ccb9c43 100644 --- a/pygeoapi/provider/base.py +++ b/pygeoapi/provider/base.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -285,7 +285,7 @@ def _load_and_prepare_item(self, item, identifier=None, msg = 'record already exists' LOGGER.error(msg) - raise ProviderInvalidDataError(msg) + raise ProviderInvalidDataError(user_msg=msg) except ProviderItemNotFoundError: LOGGER.debug('record does not exist') diff --git a/pygeoapi/pubsub/__init__.py b/pygeoapi/pubsub/__init__.py new file mode 100644 index 000000000..87d5c01b8 --- /dev/null +++ b/pygeoapi/pubsub/__init__.py @@ -0,0 +1,30 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +"""Pub/Sub module""" diff --git a/pygeoapi/pubsub/base.py b/pygeoapi/pubsub/base.py new file mode 100644 index 000000000..6523649a1 --- /dev/null +++ b/pygeoapi/pubsub/base.py @@ -0,0 +1,107 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging +import random +from urllib.parse import urlparse + +from pygeoapi.error import GenericError +from pygeoapi.util import remove_url_auth + +LOGGER = logging.getLogger(__name__) + + +class BasePubSubClient: + """Base Pub/Sub client""" + + def __init__(self, publisher_def: dict): + """ + Initialize object + + :param publisher_def: publisher definition + + :returns: pycsw.broker.base.BasePubSubClient + """ + + self.type = 'pubsub' + + try: + self.name = publisher_def['name'] + self.broker = publisher_def['broker']['url'] + except KeyError: + raise RuntimeError('name/type/broker.url are required') + + self.broker_url = urlparse(self.broker) + self.broker_safe_url = remove_url_auth(self.broker) + + self.hidden = publisher_def['broker'].get('hidden', False) + self.channel = publisher_def['broker'].get('channel') + self.client_id = f'pygeoapi-pubsub-{random.randint(0, 1000)}' + + def connect(self) -> None: + """ + Connect to a Pub/Sub broker + + :returns: None + """ + + raise NotImplementedError() + + def pub(self, channel: str, message: str) -> bool: + """ + Publish a message to a broker/channel + + :param channel: `str` of channel + :param message: `str` of message + + :returns: `bool` of publish result + """ + + raise NotImplementedError() + + def __repr__(self): + return f' {self.broker_safe_url}' + + +class PubSubClientConnectionError(GenericError): + """Pub/Sub client client connection error""" + + default_msg = 'Pub/Sub client connection error (check logs)' + + +class PubSubClientSubscriptionError(GenericError): + """Pub/Sub client client subscription error""" + + default_msg = 'Pub/Sub client subscription error (check logs)' + + +class PubSubClientPublishError(GenericError): + """Pub/Sub client client publish error""" + + default_msg = 'Pub/Sub client publish error (check logs)' diff --git a/pygeoapi/pubsub/http.py b/pygeoapi/pubsub/http.py new file mode 100644 index 000000000..a07c600ca --- /dev/null +++ b/pygeoapi/pubsub/http.py @@ -0,0 +1,105 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# Angelos Tzotsos +# +# Copyright (c) 2026 Tom Kralidis +# Copyright (c) 2025 Angelos Tzotsos +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging + +import requests + +from pygeoapi.pubsub.base import BasePubSubClient, PubSubClientConnectionError + +LOGGER = logging.getLogger(__name__) + + +class HTTPPubSubClient(BasePubSubClient): + """HTTP client""" + + def __init__(self, broker_url): + """ + Initialize object + + :param publisher_def: provider definition + + :returns: pygeoapi.pubsub.http.HTTPPubSubClient + """ + + super().__init__(broker_url) + self.name = 'HTTP' + self.type = 'http' + self.auth = None + + msg = f'Initializing to broker {self.broker_safe_url} with id {self.client_id}' # noqa + LOGGER.debug(msg) + + if None not in [self.broker_url.username, self.broker_url.password]: + LOGGER.debug('Setting credentials') + self.auth = ( + self.broker_url.username, + self.broker_url.password + ) + + def connect(self) -> None: + """ + Connect to an HTTP broker + + :returns: None + """ + + LOGGER.debug('No connection to HTTP') + pass + + def pub(self, channel: str, message: str, qos: int = 1) -> bool: + """ + Publish a message to a broker/channel + + :param channel: `str` of topic + :param message: `str` of message + + :returns: `bool` of publish result + """ + + LOGGER.debug(f'Publishing to broker {self.broker_safe_url}') + LOGGER.debug(f'Channel: {channel}') + LOGGER.debug(f'Message: {message}') + LOGGER.debug('Sanitizing channel for HTTP') + channel = channel.replace('/', '-') + channel = channel.replace(':', '-') + LOGGER.debug(f'Sanitized channel for HTTP: {channel}') + + url = f'{self.broker}/{channel}' + + try: + response = requests.post(url, auth=self.auth, json=message) + response.raise_for_status() + except Exception as err: + raise PubSubClientConnectionError(err) + + def __repr__(self): + return f' {self.broker_safe_url}' diff --git a/pygeoapi/pubsub/mqtt.py b/pygeoapi/pubsub/mqtt.py new file mode 100644 index 000000000..2afd04087 --- /dev/null +++ b/pygeoapi/pubsub/mqtt.py @@ -0,0 +1,121 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging + +from paho.mqtt import client as mqtt_client + +from pygeoapi.pubsub.base import BasePubSubClient, PubSubClientConnectionError + +LOGGER = logging.getLogger(__name__) + + +class MQTTPubSubClient(BasePubSubClient): + """MQTT client""" + + def __init__(self, broker_url): + """ + Initialize object + + :param publisher_def: provider definition + + :returns: pycsw.pubsub.mqtt.MQTTPubSubClient + """ + + super().__init__(broker_url) + self.type = 'mqtt' + self.port = self.broker_url.port + + self.userdata = {} + + msg = f'Connecting to broker {self.broker_safe_url} with id {self.client_id}' # noqa + LOGGER.debug(msg) + self.conn = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, + client_id=self.client_id) + + self.conn.enable_logger(logger=LOGGER) + + if None not in [self.broker_url.username, self.broker_url.password]: + LOGGER.debug('Setting credentials') + self.conn.username_pw_set( + self.broker_url.username, + self.broker_url.password) + + if self.port is None: + if self.broker_url.scheme == 'mqtts': + self.port = 8883 + else: + self.port = 1883 + + if self.broker_url.scheme == 'mqtts': + self.conn.tls_set(tls_version=2) + + def connect(self) -> None: + """ + Connect to an MQTT broker + + :returns: None + """ + + try: + self.conn.connect(self.broker_url.hostname, self.port) + LOGGER.debug('Connected to broker') + except Exception as err: + raise PubSubClientConnectionError(err) + + def pub(self, channel: str, message: str, qos: int = 1) -> bool: + """ + Publish a message to a broker/channel + + :param channel: `str` of channel + :param message: `str` of message + + :returns: `bool` of publish result + """ + + LOGGER.debug(f'Publishing to broker {self.broker_safe_url}') + LOGGER.debug(f'Channel: {channel}') + LOGGER.debug(f'Message: {message}') + + result = self.conn.publish(channel, message, qos) + LOGGER.debug(f'Result: {result}') + + # TODO: investigate implication + # result.wait_for_publish() + + if result.is_published: + LOGGER.debug('Message published') + return True + else: + msg = f'Publishing error code: {result[1]}' + LOGGER.warning(msg) + return False + + def __repr__(self): + return f' {self.broker_safe_url}' diff --git a/pygeoapi/resources/schemas/asyncapi/asyncapi-3.0.0.json b/pygeoapi/resources/schemas/asyncapi/asyncapi-3.0.0.json new file mode 100644 index 000000000..5f63927db --- /dev/null +++ b/pygeoapi/resources/schemas/asyncapi/asyncapi-3.0.0.json @@ -0,0 +1,9077 @@ +{ + "$id": "http://asyncapi.com/definitions/3.0.0/asyncapi.json", + "$schema": "http://json-schema.org/draft-07/schema", + "title": "AsyncAPI 3.0.0 schema.", + "type": "object", + "required": [ + "asyncapi", + "info" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "asyncapi": { + "type": "string", + "const": "3.0.0", + "description": "The AsyncAPI specification version of this document." + }, + "id": { + "type": "string", + "description": "A unique id representing the application.", + "format": "uri" + }, + "info": { + "$ref": "http://asyncapi.com/definitions/3.0.0/info.json" + }, + "servers": { + "$ref": "http://asyncapi.com/definitions/3.0.0/servers.json" + }, + "defaultContentType": { + "type": "string", + "description": "Default content type to use when encoding/decoding a message's payload." + }, + "channels": { + "$ref": "http://asyncapi.com/definitions/3.0.0/channels.json" + }, + "operations": { + "$ref": "http://asyncapi.com/definitions/3.0.0/operations.json" + }, + "components": { + "$ref": "http://asyncapi.com/definitions/3.0.0/components.json" + } + }, + "definitions": { + "http://asyncapi.com/definitions/3.0.0/specificationExtension.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json", + "description": "Any property starting with x- is valid.", + "additionalProperties": true, + "additionalItems": true + }, + "http://asyncapi.com/definitions/3.0.0/info.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/info.json", + "description": "The object provides metadata about the API. The metadata can be used by the clients if needed.", + "allOf": [ + { + "type": "object", + "required": [ + "version", + "title" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "title": { + "type": "string", + "description": "A unique and precise title of the API." + }, + "version": { + "type": "string", + "description": "A semantic version number of the API." + }, + "description": { + "type": "string", + "description": "A longer description of the API. Should be different from the title. CommonMark is allowed." + }, + "termsOfService": { + "type": "string", + "description": "A URL to the Terms of Service for the API. MUST be in the format of a URL.", + "format": "uri" + }, + "contact": { + "$ref": "http://asyncapi.com/definitions/3.0.0/contact.json" + }, + "license": { + "$ref": "http://asyncapi.com/definitions/3.0.0/license.json" + }, + "tags": { + "type": "array", + "description": "A list of tags for application API documentation control. Tags can be used for logical grouping of applications.", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + } + } + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/infoExtensions.json" + } + ], + "examples": [ + { + "title": "AsyncAPI Sample App", + "version": "1.0.1", + "description": "This is a sample app.", + "termsOfService": "https://asyncapi.org/terms/", + "contact": { + "name": "API Support", + "url": "https://www.asyncapi.org/support", + "email": "support@asyncapi.org" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "externalDocs": { + "description": "Find more info here", + "url": "https://www.asyncapi.org" + }, + "tags": [ + { + "name": "e-commerce" + } + ] + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/contact.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/contact.json", + "type": "object", + "description": "Contact information for the exposed API.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization." + }, + "url": { + "type": "string", + "description": "The URL pointing to the contact information.", + "format": "uri" + }, + "email": { + "type": "string", + "description": "The email address of the contact person/organization.", + "format": "email" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "examples": [ + { + "name": "API Support", + "url": "https://www.example.com/support", + "email": "support@example.com" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/license.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/license.json", + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the license type. It's encouraged to use an OSI compatible license." + }, + "url": { + "type": "string", + "description": "The URL pointing to the license.", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "examples": [ + { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/Reference.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/Reference.json", + "type": "object", + "description": "A simple object to allow referencing other components in the specification, internally and externally.", + "required": [ + "$ref" + ], + "properties": { + "$ref": { + "description": "The reference string.", + "$ref": "http://asyncapi.com/definitions/3.0.0/ReferenceObject.json" + } + }, + "examples": [ + { + "$ref": "#/components/schemas/Pet" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/ReferenceObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/ReferenceObject.json", + "type": "string", + "format": "uri-reference" + }, + "http://asyncapi.com/definitions/3.0.0/tag.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/tag.json", + "type": "object", + "description": "Allows adding metadata to a single tag.", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the tag." + }, + "description": { + "type": "string", + "description": "A short description for the tag. CommonMark syntax can be used for rich text representation." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "examples": [ + { + "name": "user", + "description": "User-related messages" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/externalDocs.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/externalDocs.json", + "type": "object", + "additionalProperties": false, + "description": "Allows referencing an external resource for extended documentation.", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string", + "description": "A short description of the target documentation. CommonMark syntax can be used for rich text representation." + }, + "url": { + "type": "string", + "description": "The URL for the target documentation. This MUST be in the form of an absolute URL.", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "examples": [ + { + "description": "Find more info here", + "url": "https://example.com" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/infoExtensions.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/infoExtensions.json", + "type": "object", + "description": "The object that lists all the extensions of Info", + "properties": { + "x-x": { + "$ref": "http://asyncapi.com/extensions/x/0.1.0/schema.json" + }, + "x-linkedin": { + "$ref": "http://asyncapi.com/extensions/linkedin/0.1.0/schema.json" + } + } + }, + "http://asyncapi.com/extensions/x/0.1.0/schema.json": { + "$id": "http://asyncapi.com/extensions/x/0.1.0/schema.json", + "type": "string", + "description": "This extension allows you to provide the Twitter username of the account representing the team/company of the API.", + "example": [ + "sambhavgupta75", + "AsyncAPISpec" + ] + }, + "http://asyncapi.com/extensions/linkedin/0.1.0/schema.json": { + "$id": "http://asyncapi.com/extensions/linkedin/0.1.0/schema.json", + "type": "string", + "pattern": "^http(s)?://(www\\.)?linkedin\\.com.*$", + "description": "This extension allows you to provide the Linkedin profile URL of the account representing the team/company of the API.", + "example": [ + "https://www.linkedin.com/company/asyncapi/", + "https://www.linkedin.com/in/sambhavgupta0705/" + ] + }, + "http://asyncapi.com/definitions/3.0.0/servers.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/servers.json", + "description": "An object representing multiple servers.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/server.json" + } + ] + }, + "examples": [ + { + "development": { + "host": "localhost:5672", + "description": "Development AMQP broker.", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "tags": [ + { + "name": "env:development", + "description": "This environment is meant for developers to run their own tests." + } + ] + }, + "staging": { + "host": "rabbitmq-staging.in.mycompany.com:5672", + "description": "RabbitMQ broker for the staging environment.", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "tags": [ + { + "name": "env:staging", + "description": "This environment is a replica of the production environment." + } + ] + }, + "production": { + "host": "rabbitmq.in.mycompany.com:5672", + "description": "RabbitMQ broker for the production environment.", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "tags": [ + { + "name": "env:production", + "description": "This environment is the live environment available for final users." + } + ] + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/server.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/server.json", + "type": "object", + "description": "An object representing a message broker, a server or any other kind of computer program capable of sending and/or receiving data.", + "required": [ + "host", + "protocol" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "host": { + "type": "string", + "description": "The server host name. It MAY include the port. This field supports Server Variables. Variable substitutions will be made when a variable is named in {braces}." + }, + "pathname": { + "type": "string", + "description": "The path to a resource in the host. This field supports Server Variables. Variable substitutions will be made when a variable is named in {braces}." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the server." + }, + "summary": { + "type": "string", + "description": "A brief summary of the server." + }, + "description": { + "type": "string", + "description": "A longer description of the server. CommonMark is allowed." + }, + "protocol": { + "type": "string", + "description": "The protocol this server supports for connection." + }, + "protocolVersion": { + "type": "string", + "description": "An optional string describing the server. CommonMark syntax MAY be used for rich text representation." + }, + "variables": { + "$ref": "http://asyncapi.com/definitions/3.0.0/serverVariables.json" + }, + "security": { + "$ref": "http://asyncapi.com/definitions/3.0.0/securityRequirements.json" + }, + "tags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + }, + "bindings": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/serverBindingsObject.json" + } + ] + } + }, + "examples": [ + { + "host": "kafka.in.mycompany.com:9092", + "description": "Production Kafka broker.", + "protocol": "kafka", + "protocolVersion": "3.2" + }, + { + "host": "rabbitmq.in.mycompany.com:5672", + "pathname": "/production", + "protocol": "amqp", + "description": "Production RabbitMQ broker (uses the `production` vhost)." + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/serverVariables.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/serverVariables.json", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/serverVariable.json" + } + ] + } + }, + "http://asyncapi.com/definitions/3.0.0/serverVariable.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/serverVariable.json", + "type": "object", + "description": "An object representing a Server Variable for server URL template substitution.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "enum": { + "type": "array", + "description": "An enumeration of string values to be used if the substitution options are from a limited set.", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "default": { + "type": "string", + "description": "The default value to use for substitution, and to send, if an alternate value is not supplied." + }, + "description": { + "type": "string", + "description": "An optional description for the server variable. CommonMark syntax MAY be used for rich text representation." + }, + "examples": { + "type": "array", + "description": "An array of examples of the server variable.", + "items": { + "type": "string" + } + } + }, + "examples": [ + { + "host": "rabbitmq.in.mycompany.com:5672", + "pathname": "/{env}", + "protocol": "amqp", + "description": "RabbitMQ broker. Use the `env` variable to point to either `production` or `staging`.", + "variables": { + "env": { + "description": "Environment to connect to. It can be either `production` or `staging`.", + "enum": [ + "production", + "staging" + ] + } + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/securityRequirements.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/securityRequirements.json", + "description": "An array representing security requirements.", + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/SecurityScheme.json" + } + ] + } + }, + "http://asyncapi.com/definitions/3.0.0/SecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/SecurityScheme.json", + "description": "Defines a security scheme that can be used by the operations.", + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/userPassword.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/apiKey.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/X509.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/symmetricEncryption.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/asymmetricEncryption.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/HTTPSecurityScheme.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/oauth2Flows.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/openIdConnect.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/SaslSecurityScheme.json" + } + ], + "examples": [ + { + "type": "userPassword" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/userPassword.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/userPassword.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "userPassword" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "userPassword" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/apiKey.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/apiKey.json", + "type": "object", + "required": [ + "type", + "in" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme", + "enum": [ + "apiKey" + ] + }, + "in": { + "type": "string", + "description": " The location of the API key.", + "enum": [ + "user", + "password" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "apiKey", + "in": "user" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/X509.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/X509.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "X509" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "X509" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/symmetricEncryption.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/symmetricEncryption.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "symmetricEncryption" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "symmetricEncryption" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/asymmetricEncryption.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/asymmetricEncryption.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "asymmetricEncryption" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false + }, + "http://asyncapi.com/definitions/3.0.0/HTTPSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/HTTPSecurityScheme.json", + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/NonBearerHTTPSecurityScheme.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/BearerHTTPSecurityScheme.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/APIKeyHTTPSecurityScheme.json" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/NonBearerHTTPSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/NonBearerHTTPSecurityScheme.json", + "not": { + "type": "object", + "properties": { + "scheme": { + "type": "string", + "description": "A short description for security scheme.", + "enum": [ + "bearer" + ] + } + } + }, + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string", + "description": "The name of the HTTP Authorization scheme to be used in the Authorization header as defined in RFC7235." + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + }, + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false + }, + "http://asyncapi.com/definitions/3.0.0/BearerHTTPSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/BearerHTTPSecurityScheme.json", + "type": "object", + "required": [ + "type", + "scheme" + ], + "properties": { + "scheme": { + "type": "string", + "description": "The name of the HTTP Authorization scheme to be used in the Authorization header as defined in RFC7235.", + "enum": [ + "bearer" + ] + }, + "bearerFormat": { + "type": "string", + "description": "A hint to the client to identify how the bearer token is formatted. Bearer tokens are usually generated by an authorization server, so this information is primarily for documentation purposes." + }, + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "http" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false + }, + "http://asyncapi.com/definitions/3.0.0/APIKeyHTTPSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/APIKeyHTTPSecurityScheme.json", + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "httpApiKey" + ] + }, + "name": { + "type": "string", + "description": "The name of the header, query or cookie parameter to be used." + }, + "in": { + "type": "string", + "description": "The location of the API key", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "httpApiKey", + "name": "api_key", + "in": "header" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/oauth2Flows.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/oauth2Flows.json", + "type": "object", + "description": "Allows configuration of the supported OAuth Flows.", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "oauth2" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + }, + "flows": { + "type": "object", + "properties": { + "implicit": { + "description": "Configuration for the OAuth Implicit flow.", + "allOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/oauth2Flow.json" + }, + { + "required": [ + "authorizationUrl", + "availableScopes" + ] + }, + { + "not": { + "required": [ + "tokenUrl" + ] + } + } + ] + }, + "password": { + "description": "Configuration for the OAuth Resource Owner Protected Credentials flow.", + "allOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/oauth2Flow.json" + }, + { + "required": [ + "tokenUrl", + "availableScopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "clientCredentials": { + "description": "Configuration for the OAuth Client Credentials flow.", + "allOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/oauth2Flow.json" + }, + { + "required": [ + "tokenUrl", + "availableScopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "authorizationCode": { + "description": "Configuration for the OAuth Authorization Code flow.", + "allOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/oauth2Flow.json" + }, + { + "required": [ + "authorizationUrl", + "tokenUrl", + "availableScopes" + ] + } + ] + } + }, + "additionalProperties": false + }, + "scopes": { + "type": "array", + "description": "List of the needed scope names.", + "items": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + } + }, + "http://asyncapi.com/definitions/3.0.0/oauth2Flow.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/oauth2Flow.json", + "type": "object", + "description": "Configuration details for a supported OAuth Flow", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "The authorization URL to be used for this flow. This MUST be in the form of an absolute URL." + }, + "tokenUrl": { + "type": "string", + "format": "uri", + "description": "The token URL to be used for this flow. This MUST be in the form of an absolute URL." + }, + "refreshUrl": { + "type": "string", + "format": "uri", + "description": "The URL to be used for obtaining refresh tokens. This MUST be in the form of an absolute URL." + }, + "availableScopes": { + "$ref": "http://asyncapi.com/definitions/3.0.0/oauth2Scopes.json", + "description": "The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "authorizationUrl": "https://example.com/api/oauth/dialog", + "tokenUrl": "https://example.com/api/oauth/token", + "availableScopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/oauth2Scopes.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/oauth2Scopes.json", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "http://asyncapi.com/definitions/3.0.0/openIdConnect.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/openIdConnect.json", + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "openIdConnect" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri", + "description": "OpenId Connect URL to discover OAuth2 configuration values. This MUST be in the form of an absolute URL." + }, + "scopes": { + "type": "array", + "description": "List of the needed scope names. An empty array means no scopes are needed.", + "items": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false + }, + "http://asyncapi.com/definitions/3.0.0/SaslSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/SaslSecurityScheme.json", + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/SaslPlainSecurityScheme.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/SaslScramSecurityScheme.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/SaslGssapiSecurityScheme.json" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/SaslPlainSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/SaslPlainSecurityScheme.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme. Valid values", + "enum": [ + "plain" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "scramSha512" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/SaslScramSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/SaslScramSecurityScheme.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "scramSha256", + "scramSha512" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "scramSha512" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/SaslGssapiSecurityScheme.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/SaslGssapiSecurityScheme.json", + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "gssapi" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "scramSha512" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/serverBindingsObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/serverBindingsObject.json", + "type": "object", + "description": "Map describing protocol-specific definitions for a server.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "http": {}, + "ws": {}, + "amqp": {}, + "amqp1": {}, + "mqtt": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/mqtt/0.2.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/mqtt/0.2.0/server.json" + } + } + ] + }, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.4.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.3.0/server.json" + } + } + ] + }, + "anypointmq": {}, + "nats": {}, + "jms": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/server.json" + } + } + ] + }, + "sns": {}, + "sqs": {}, + "stomp": {}, + "redis": {}, + "ibmmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/ibmmq/0.1.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/ibmmq/0.1.0/server.json" + } + } + ] + }, + "solace": { + "properties": { + "bindingVersion": { + "enum": [ + "0.4.0", + "0.3.0", + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.4.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.4.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.3.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.2.0/server.json" + } + } + ] + }, + "googlepubsub": {}, + "pulsar": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/pulsar/0.1.0/server.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/pulsar/0.1.0/server.json" + } + } + ] + } + } + }, + "http://asyncapi.com/bindings/mqtt/0.2.0/server.json": { + "$id": "http://asyncapi.com/bindings/mqtt/0.2.0/server.json", + "title": "Server Schema", + "description": "This object contains information about the server representation in MQTT.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "clientId": { + "type": "string", + "description": "The client identifier." + }, + "cleanSession": { + "type": "boolean", + "description": "Whether to create a persistent connection or not. When 'false', the connection will be persistent. This is called clean start in MQTTv5." + }, + "lastWill": { + "type": "object", + "description": "Last Will and Testament configuration.", + "properties": { + "topic": { + "type": "string", + "description": "The topic where the Last Will and Testament message will be sent." + }, + "qos": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "Defines how hard the broker/client will try to ensure that the Last Will and Testament message is received. Its value MUST be either 0, 1 or 2." + }, + "message": { + "type": "string", + "description": "Last Will message." + }, + "retain": { + "type": "boolean", + "description": "Whether the broker should retain the Last Will and Testament message or not." + } + } + }, + "keepAlive": { + "type": "integer", + "description": "Interval in seconds of the longest period of time the broker and the client can endure without sending a message." + }, + "sessionExpiryInterval": { + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "Interval time in seconds or a Schema Object containing the definition of the interval. The broker maintains a session for a disconnected client until this interval expires." + }, + "maximumPacketSize": { + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "Number of bytes or a Schema Object representing the Maximum Packet Size the Client is willing to accept." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "clientId": "guest", + "cleanSession": true, + "lastWill": { + "topic": "/last-wills", + "qos": 2, + "message": "Guest gone offline.", + "retain": false + }, + "keepAlive": 60, + "sessionExpiryInterval": 120, + "maximumPacketSize": 1024, + "bindingVersion": "0.2.0" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/schema.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "The Schema Object allows the definition of input and output data types. These types can be objects, but also primitives and arrays. This object is a superset of the JSON Schema Specification Draft 07. The empty schema (which allows any instance to validate) MAY be represented by the boolean value true and a schema which allows no instance to validate MAY be represented by the boolean value false.", + "allOf": [ + { + "$ref": "http://json-schema.org/draft-07/schema#" + }, + { + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "type": "boolean" + } + ], + "default": {} + }, + "items": { + "anyOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + } + ], + "default": {} + }, + "allOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + }, + "oneOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + }, + "anyOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + }, + "not": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "default": {} + }, + "propertyNames": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "contains": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "discriminator": { + "type": "string", + "description": "Adds support for polymorphism. The discriminator is the schema property name that is used to differentiate between other schema that inherit this schema. The property name used MUST be defined at this schema and it MUST be in the required property list. When used, the value MUST be the name of this schema or any schema that inherits it. See Composition and Inheritance for more details." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + }, + "deprecated": { + "type": "boolean", + "description": "Specifies that a schema is deprecated and SHOULD be transitioned out of usage. Default value is false.", + "default": false + } + } + } + ] + }, + "http://json-schema.org/draft-07/schema": { + "$id": "http://json-schema.org/draft-07/schema", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#" + } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/nonNegativeInteger" + }, + { + "default": 0 + } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [] + } + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { + "$ref": "#/definitions/nonNegativeInteger" + }, + "minLength": { + "$ref": "#/definitions/nonNegativeIntegerDefault0" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "$ref": "#" + }, + "items": { + "anyOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/schemaArray" + } + ], + "default": true + }, + "maxItems": { + "$ref": "#/definitions/nonNegativeInteger" + }, + "minItems": { + "$ref": "#/definitions/nonNegativeIntegerDefault0" + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { + "$ref": "#" + }, + "maxProperties": { + "$ref": "#/definitions/nonNegativeInteger" + }, + "minProperties": { + "$ref": "#/definitions/nonNegativeIntegerDefault0" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "additionalProperties": { + "$ref": "#" + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#" + }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#" + }, + "propertyNames": { + "format": "regex" + }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/stringArray" + } + ] + } + }, + "propertyNames": { + "$ref": "#" + }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { + "$ref": "#/definitions/simpleTypes" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/simpleTypes" + }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "contentEncoding": { + "type": "string" + }, + "if": { + "$ref": "#" + }, + "then": { + "$ref": "#" + }, + "else": { + "$ref": "#" + }, + "allOf": { + "$ref": "#/definitions/schemaArray" + }, + "anyOf": { + "$ref": "#/definitions/schemaArray" + }, + "oneOf": { + "$ref": "#/definitions/schemaArray" + }, + "not": { + "$ref": "#" + } + }, + "default": true + }, + "http://asyncapi.com/bindings/kafka/0.5.0/server.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.5.0/server.json", + "title": "Server Schema", + "description": "This object contains server connection information to a Kafka broker. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "schemaRegistryUrl": { + "type": "string", + "description": "API URL for the Schema Registry used when producing Kafka messages (if a Schema Registry was used)." + }, + "schemaRegistryVendor": { + "type": "string", + "description": "The vendor of the Schema Registry and Kafka serdes library that should be used." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "schemaRegistryUrl": "https://my-schema-registry.com", + "schemaRegistryVendor": "confluent", + "bindingVersion": "0.5.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.4.0/server.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.4.0/server.json", + "title": "Server Schema", + "description": "This object contains server connection information to a Kafka broker. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "schemaRegistryUrl": { + "type": "string", + "description": "API URL for the Schema Registry used when producing Kafka messages (if a Schema Registry was used)." + }, + "schemaRegistryVendor": { + "type": "string", + "description": "The vendor of the Schema Registry and Kafka serdes library that should be used." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "schemaRegistryUrl": "https://my-schema-registry.com", + "schemaRegistryVendor": "confluent", + "bindingVersion": "0.4.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.3.0/server.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.3.0/server.json", + "title": "Server Schema", + "description": "This object contains server connection information to a Kafka broker. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "schemaRegistryUrl": { + "type": "string", + "description": "API URL for the Schema Registry used when producing Kafka messages (if a Schema Registry was used)." + }, + "schemaRegistryVendor": { + "type": "string", + "description": "The vendor of the Schema Registry and Kafka serdes library that should be used." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "schemaRegistryUrl": "https://my-schema-registry.com", + "schemaRegistryVendor": "confluent", + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/jms/0.0.1/server.json": { + "$id": "http://asyncapi.com/bindings/jms/0.0.1/server.json", + "title": "Server Schema", + "description": "This object contains configuration for describing a JMS broker as an AsyncAPI server. This objects only contains configuration that can not be provided in the AsyncAPI standard server object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "required": [ + "jmsConnectionFactory" + ], + "properties": { + "jmsConnectionFactory": { + "type": "string", + "description": "The classname of the ConnectionFactory implementation for the JMS Provider." + }, + "properties": { + "type": "array", + "items": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/server.json#/definitions/property" + }, + "description": "Additional properties to set on the JMS ConnectionFactory implementation for the JMS Provider." + }, + "clientID": { + "type": "string", + "description": "A client identifier for applications that use this JMS connection factory. If the Client ID Policy is set to 'Restricted' (the default), then configuring a Client ID on the ConnectionFactory prevents more than one JMS client from using a connection from this factory." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "definitions": { + "property": { + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of a property" + }, + "value": { + "type": [ + "string", + "boolean", + "number", + "null" + ], + "description": "The name of a property" + } + } + } + }, + "examples": [ + { + "jmsConnectionFactory": "org.apache.activemq.ActiveMQConnectionFactory", + "properties": [ + { + "name": "disableTimeStampsByDefault", + "value": false + } + ], + "clientID": "my-application-1", + "bindingVersion": "0.0.1" + } + ] + }, + "http://asyncapi.com/bindings/ibmmq/0.1.0/server.json": { + "$id": "http://asyncapi.com/bindings/ibmmq/0.1.0/server.json", + "title": "IBM MQ server bindings object", + "description": "This object contains server connection information about the IBM MQ server, referred to as an IBM MQ queue manager. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "groupId": { + "type": "string", + "description": "Defines a logical group of IBM MQ server objects. This is necessary to specify multi-endpoint configurations used in high availability deployments. If omitted, the server object is not part of a group." + }, + "ccdtQueueManagerName": { + "type": "string", + "default": "*", + "description": "The name of the IBM MQ queue manager to bind to in the CCDT file." + }, + "cipherSpec": { + "type": "string", + "description": "The recommended cipher specification used to establish a TLS connection between the client and the IBM MQ queue manager. More information on SSL/TLS cipher specifications supported by IBM MQ can be found on this page in the IBM MQ Knowledge Center." + }, + "multiEndpointServer": { + "type": "boolean", + "default": false, + "description": "If 'multiEndpointServer' is 'true' then multiple connections can be workload balanced and applications should not make assumptions as to where messages are processed. Where message ordering, or affinity to specific message resources is necessary, a single endpoint ('multiEndpointServer' = 'false') may be required." + }, + "heartBeatInterval": { + "type": "integer", + "minimum": 0, + "maximum": 999999, + "default": 300, + "description": "The recommended value (in seconds) for the heartbeat sent to the queue manager during periods of inactivity. A value of zero means that no heart beats are sent. A value of 1 means that the client will use the value defined by the queue manager. More information on heart beat interval can be found on this page in the IBM MQ Knowledge Center." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "groupId": "PRODCLSTR1", + "cipherSpec": "ANY_TLS12_OR_HIGHER", + "bindingVersion": "0.1.0" + }, + { + "groupId": "PRODCLSTR1", + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/bindings/solace/0.4.0/server.json": { + "$id": "http://asyncapi.com/bindings/solace/0.4.0/server.json", + "title": "Solace server bindings object", + "description": "This object contains server connection information about the Solace broker. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "msgVpn": { + "type": "string", + "description": "The name of the Virtual Private Network to connect to on the Solace broker." + }, + "clientName": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "A unique client name to use to register to the appliance. If specified, it must be a valid Topic name, and a maximum of 160 bytes in length when encoded as UTF-8." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "msgVpn": "ProdVPN", + "bindingVersion": "0.4.0" + } + ] + }, + "http://asyncapi.com/bindings/solace/0.3.0/server.json": { + "$id": "http://asyncapi.com/bindings/solace/0.3.0/server.json", + "title": "Solace server bindings object", + "description": "This object contains server connection information about the Solace broker. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "msgVpn": { + "type": "string", + "description": "The name of the Virtual Private Network to connect to on the Solace broker." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "msgVpn": "ProdVPN", + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/solace/0.2.0/server.json": { + "$id": "http://asyncapi.com/bindings/solace/0.2.0/server.json", + "title": "Solace server bindings object", + "description": "This object contains server connection information about the Solace broker. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "msvVpn": { + "type": "string", + "description": "The name of the Virtual Private Network to connect to on the Solace broker." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "msgVpn": "ProdVPN", + "bindingVersion": "0.2.0" + } + ] + }, + "http://asyncapi.com/bindings/pulsar/0.1.0/server.json": { + "$id": "http://asyncapi.com/bindings/pulsar/0.1.0/server.json", + "title": "Server Schema", + "description": "This object contains server information of Pulsar broker, which covers cluster and tenant admin configuration. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "tenant": { + "type": "string", + "description": "The pulsar tenant. If omitted, 'public' MUST be assumed." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "tenant": "contoso", + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/channels.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/channels.json", + "type": "object", + "description": "An object containing all the Channel Object definitions the Application MUST use during runtime.", + "additionalProperties": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/channel.json" + } + ] + }, + "examples": [ + { + "userSignedUp": { + "address": "user.signedup", + "messages": { + "userSignedUp": { + "$ref": "#/components/messages/userSignedUp" + } + } + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/channel.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/channel.json", + "type": "object", + "description": "Describes a shared communication channel.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "address": { + "type": [ + "string", + "null" + ], + "description": "An optional string representation of this channel's address. The address is typically the \"topic name\", \"routing key\", \"event type\", or \"path\". When `null` or absent, it MUST be interpreted as unknown. This is useful when the address is generated dynamically at runtime or can't be known upfront. It MAY contain Channel Address Expressions." + }, + "messages": { + "$ref": "http://asyncapi.com/definitions/3.0.0/channelMessages.json" + }, + "parameters": { + "$ref": "http://asyncapi.com/definitions/3.0.0/parameters.json" + }, + "title": { + "type": "string", + "description": "A human-friendly title for the channel." + }, + "summary": { + "type": "string", + "description": "A brief summary of the channel." + }, + "description": { + "type": "string", + "description": "A longer description of the channel. CommonMark is allowed." + }, + "servers": { + "type": "array", + "description": "The references of the servers on which this channel is available. If absent or empty then this channel must be available on all servers.", + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + "uniqueItems": true + }, + "tags": { + "type": "array", + "description": "A list of tags for logical grouping of channels.", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + }, + "bindings": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/channelBindingsObject.json" + } + ] + } + }, + "examples": [ + { + "address": "users.{userId}", + "title": "Users channel", + "description": "This channel is used to exchange messages about user events.", + "messages": { + "userSignedUp": { + "$ref": "#/components/messages/userSignedUp" + }, + "userCompletedOrder": { + "$ref": "#/components/messages/userCompletedOrder" + } + }, + "parameters": { + "userId": { + "$ref": "#/components/parameters/userId" + } + }, + "servers": [ + { + "$ref": "#/servers/rabbitmqInProd" + }, + { + "$ref": "#/servers/rabbitmqInStaging" + } + ], + "bindings": { + "amqp": { + "is": "queue", + "queue": { + "exclusive": true + } + } + }, + "tags": [ + { + "name": "user", + "description": "User-related messages" + } + ], + "externalDocs": { + "description": "Find more info here", + "url": "https://example.com" + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/channelMessages.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/channelMessages.json", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageObject.json" + } + ] + }, + "description": "A map of the messages that will be sent to this channel by any application at any time. **Every message sent to this channel MUST be valid against one, and only one, of the message objects defined in this map.**" + }, + "http://asyncapi.com/definitions/3.0.0/messageObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/messageObject.json", + "type": "object", + "description": "Describes a message received on a given channel and operation.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "contentType": { + "type": "string", + "description": "The content type to use when encoding/decoding a message's payload. The value MUST be a specific media type (e.g. application/json). When omitted, the value MUST be the one specified on the defaultContentType field." + }, + "headers": { + "$ref": "http://asyncapi.com/definitions/3.0.0/anySchema.json" + }, + "payload": { + "$ref": "http://asyncapi.com/definitions/3.0.0/anySchema.json" + }, + "correlationId": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/correlationId.json" + } + ] + }, + "tags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "description": "List of examples.", + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageExampleObject.json" + } + }, + "bindings": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageBindingsObject.json" + } + ] + }, + "traits": { + "type": "array", + "description": "A list of traits to apply to the message object. Traits MUST be merged using traits merge mechanism. The resulting object MUST be a valid Message Object.", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageTrait.json" + }, + { + "type": "array", + "items": [ + { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageTrait.json" + } + ] + }, + { + "type": "object", + "additionalItems": true + } + ] + } + ] + } + } + }, + "examples": [ + { + "messageId": "userSignup", + "name": "UserSignup", + "title": "User signup", + "summary": "Action to sign a user up.", + "description": "A longer description", + "contentType": "application/json", + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + }, + { + "name": "register" + } + ], + "headers": { + "type": "object", + "properties": { + "correlationId": { + "description": "Correlation ID set by application", + "type": "string" + }, + "applicationInstanceId": { + "description": "Unique identifier for a given instance of the publishing application", + "type": "string" + } + } + }, + "payload": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/userCreate" + }, + "signup": { + "$ref": "#/components/schemas/signup" + } + } + }, + "correlationId": { + "description": "Default Correlation ID", + "location": "$message.header#/correlationId" + }, + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "examples": [ + { + "name": "SimpleSignup", + "summary": "A simple UserSignup example message", + "headers": { + "correlationId": "my-correlation-id", + "applicationInstanceId": "myInstanceId" + }, + "payload": { + "user": { + "someUserKey": "someUserValue" + }, + "signup": { + "someSignupKey": "someSignupValue" + } + } + } + ] + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/anySchema.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/anySchema.json", + "if": { + "required": [ + "schema" + ] + }, + "then": { + "$ref": "http://asyncapi.com/definitions/3.0.0/multiFormatSchema.json" + }, + "else": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "description": "An object representing either a schema or a multiFormatSchema based on the existence of the 'schema' property. If the property 'schema' is present, use the multi-format schema. Use the default AsyncAPI Schema otherwise." + }, + "http://asyncapi.com/definitions/3.0.0/multiFormatSchema.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/multiFormatSchema.json", + "description": "The Multi Format Schema Object represents a schema definition. It differs from the Schema Object in that it supports multiple schema formats or languages (e.g., JSON Schema, Avro, etc.).", + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "if": { + "not": { + "type": "object" + } + }, + "then": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + "else": { + "properties": { + "schemaFormat": { + "description": "A string containing the name of the schema format that is used to define the information. If schemaFormat is missing, it MUST default to application/vnd.aai.asyncapi+json;version={{asyncapi}} where {{asyncapi}} matches the AsyncAPI Version String. In such a case, this would make the Multi Format Schema Object equivalent to the Schema Object. When using Reference Object within the schema, the schemaFormat of the resource being referenced MUST match the schemaFormat of the schema that contains the initial reference. For example, if you reference Avro schema, then schemaFormat of referencing resource and the resource being reference MUST match.", + "anyOf": [ + { + "type": "string" + }, + { + "description": "All the schema formats tooling MUST support", + "enum": [ + "application/schema+json;version=draft-07", + "application/schema+yaml;version=draft-07", + "application/vnd.aai.asyncapi;version=3.0.0", + "application/vnd.aai.asyncapi+json;version=3.0.0", + "application/vnd.aai.asyncapi+yaml;version=3.0.0" + ] + }, + { + "description": "All the schema formats tools are RECOMMENDED to support", + "enum": [ + "application/vnd.oai.openapi;version=3.0.0", + "application/vnd.oai.openapi+json;version=3.0.0", + "application/vnd.oai.openapi+yaml;version=3.0.0", + "application/vnd.apache.avro;version=1.9.0", + "application/vnd.apache.avro+json;version=1.9.0", + "application/vnd.apache.avro+yaml;version=1.9.0", + "application/raml+yaml;version=1.0" + ] + } + ] + } + }, + "allOf": [ + { + "if": { + "not": { + "description": "If no schemaFormat has been defined, default to schema or reference", + "required": [ + "schemaFormat" + ] + } + }, + "then": { + "properties": { + "schema": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + } + } + }, + { + "if": { + "description": "If schemaFormat has been defined check if it's one of the AsyncAPI Schema Object formats", + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/vnd.aai.asyncapi;version=2.0.0", + "application/vnd.aai.asyncapi+json;version=2.0.0", + "application/vnd.aai.asyncapi+yaml;version=2.0.0", + "application/vnd.aai.asyncapi;version=2.1.0", + "application/vnd.aai.asyncapi+json;version=2.1.0", + "application/vnd.aai.asyncapi+yaml;version=2.1.0", + "application/vnd.aai.asyncapi;version=2.2.0", + "application/vnd.aai.asyncapi+json;version=2.2.0", + "application/vnd.aai.asyncapi+yaml;version=2.2.0", + "application/vnd.aai.asyncapi;version=2.3.0", + "application/vnd.aai.asyncapi+json;version=2.3.0", + "application/vnd.aai.asyncapi+yaml;version=2.3.0", + "application/vnd.aai.asyncapi;version=2.4.0", + "application/vnd.aai.asyncapi+json;version=2.4.0", + "application/vnd.aai.asyncapi+yaml;version=2.4.0", + "application/vnd.aai.asyncapi;version=2.5.0", + "application/vnd.aai.asyncapi+json;version=2.5.0", + "application/vnd.aai.asyncapi+yaml;version=2.5.0", + "application/vnd.aai.asyncapi;version=2.6.0", + "application/vnd.aai.asyncapi+json;version=2.6.0", + "application/vnd.aai.asyncapi+yaml;version=2.6.0", + "application/vnd.aai.asyncapi;version=3.0.0", + "application/vnd.aai.asyncapi+json;version=3.0.0", + "application/vnd.aai.asyncapi+yaml;version=3.0.0" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + } + } + }, + { + "if": { + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/schema+json;version=draft-07", + "application/schema+yaml;version=draft-07" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "$ref": "http://json-schema.org/draft-07/schema" + } + } + } + }, + { + "if": { + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/vnd.oai.openapi;version=3.0.0", + "application/vnd.oai.openapi+json;version=3.0.0", + "application/vnd.oai.openapi+yaml;version=3.0.0" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/openapiSchema_3_0.json" + } + ] + } + } + } + }, + { + "if": { + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/vnd.apache.avro;version=1.9.0", + "application/vnd.apache.avro+json;version=1.9.0", + "application/vnd.apache.avro+yaml;version=1.9.0" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/avroSchema_v1.json" + } + ] + } + } + } + } + ] + } + }, + "http://asyncapi.com/definitions/3.0.0/openapiSchema_3_0.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/openapiSchema_3_0.json", + "type": "object", + "definitions": { + "ExternalDocumentation": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Discriminator": { + "type": "object", + "required": [ + "propertyName" + ], + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "patternProperties": { + "^\\$ref$": { + "type": "string", + "format": "uri-reference" + } + } + }, + "XML": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + } + }, + "properties": { + "title": { + "type": "string" + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "minLength": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { + "type": "integer", + "minimum": 0 + }, + "minProperties": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": false + }, + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string" + ] + }, + "not": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "allOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "oneOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "anyOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "items": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#" + }, + { + "$ref": "#/definitions/Reference" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "description": { + "type": "string" + }, + "format": { + "type": "string" + }, + "default": true, + "nullable": { + "type": "boolean", + "default": false + }, + "discriminator": { + "$ref": "#/definitions/Discriminator" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "example": true, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/XML" + } + }, + "patternProperties": { + "^x-": true + }, + "additionalProperties": false + }, + "http://asyncapi.com/definitions/3.0.0/avroSchema_v1.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/avroSchema_v1.json", + "definitions": { + "avroSchema": { + "title": "Avro Schema", + "description": "Root Schema", + "oneOf": [ + { + "$ref": "#/definitions/types" + } + ] + }, + "types": { + "title": "Avro Types", + "description": "Allowed Avro types", + "oneOf": [ + { + "$ref": "#/definitions/primitiveType" + }, + { + "$ref": "#/definitions/primitiveTypeWithMetadata" + }, + { + "$ref": "#/definitions/customTypeReference" + }, + { + "$ref": "#/definitions/avroRecord" + }, + { + "$ref": "#/definitions/avroEnum" + }, + { + "$ref": "#/definitions/avroArray" + }, + { + "$ref": "#/definitions/avroMap" + }, + { + "$ref": "#/definitions/avroFixed" + }, + { + "$ref": "#/definitions/avroUnion" + } + ] + }, + "primitiveType": { + "title": "Primitive Type", + "description": "Basic type primitives.", + "type": "string", + "enum": [ + "null", + "boolean", + "int", + "long", + "float", + "double", + "bytes", + "string" + ] + }, + "primitiveTypeWithMetadata": { + "title": "Primitive Type With Metadata", + "description": "A primitive type with metadata attached.", + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/primitiveType" + } + }, + "required": [ + "type" + ] + }, + "customTypeReference": { + "title": "Custom Type", + "description": "Reference to a ComplexType", + "not": { + "$ref": "#/definitions/primitiveType" + }, + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "avroUnion": { + "title": "Union", + "description": "A Union of types", + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema" + }, + "minItems": 1 + }, + "avroField": { + "title": "Field", + "description": "A field within a Record", + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/name" + }, + "type": { + "$ref": "#/definitions/types" + }, + "doc": { + "type": "string" + }, + "default": true, + "order": { + "enum": [ + "ascending", + "descending", + "ignore" + ] + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + } + }, + "required": [ + "name", + "type" + ] + }, + "avroRecord": { + "title": "Record", + "description": "A Record", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "record" + }, + "name": { + "$ref": "#/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/avroField" + } + } + }, + "required": [ + "type", + "name", + "fields" + ] + }, + "avroEnum": { + "title": "Enum", + "description": "An enumeration", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "enum" + }, + "name": { + "$ref": "#/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + }, + "symbols": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + } + }, + "required": [ + "type", + "name", + "symbols" + ] + }, + "avroArray": { + "title": "Array", + "description": "An array", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + }, + "name": { + "$ref": "#/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + }, + "items": { + "$ref": "#/definitions/types" + } + }, + "required": [ + "type", + "items" + ] + }, + "avroMap": { + "title": "Map", + "description": "A map of values", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "map" + }, + "name": { + "$ref": "#/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + }, + "values": { + "$ref": "#/definitions/types" + } + }, + "required": [ + "type", + "values" + ] + }, + "avroFixed": { + "title": "Fixed", + "description": "A fixed sized array of bytes", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "fixed" + }, + "name": { + "$ref": "#/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/name" + } + }, + "size": { + "type": "number" + } + }, + "required": [ + "type", + "name", + "size" + ] + }, + "name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "namespace": { + "type": "string", + "pattern": "^([A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*)*$" + } + }, + "description": "Json-Schema definition for Avro AVSC files.", + "oneOf": [ + { + "$ref": "#/definitions/avroSchema" + } + ], + "title": "Avro Schema Definition" + }, + "http://asyncapi.com/definitions/3.0.0/correlationId.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/correlationId.json", + "type": "object", + "description": "An object that specifies an identifier at design time that can used for message tracing and correlation.", + "required": [ + "location" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A optional description of the correlation ID. GitHub Flavored Markdown is allowed." + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the correlation ID", + "pattern": "^\\$message\\.(header|payload)#(\\/(([^\\/~])|(~[01]))*)*" + } + }, + "examples": [ + { + "description": "Default Correlation ID", + "location": "$message.header#/correlationId" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/messageExampleObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/messageExampleObject.json", + "type": "object", + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "payload" + ] + }, + { + "required": [ + "headers" + ] + } + ], + "properties": { + "name": { + "type": "string", + "description": "Machine readable name of the message example." + }, + "summary": { + "type": "string", + "description": "A brief summary of the message example." + }, + "headers": { + "type": "object", + "description": "Example of the application headers. It MUST be a map of key-value pairs." + }, + "payload": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ], + "description": "Example of the message payload. It can be of any type." + } + } + }, + "http://asyncapi.com/definitions/3.0.0/messageBindingsObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/messageBindingsObject.json", + "type": "object", + "description": "Map describing protocol-specific definitions for a message.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "http": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/http/0.3.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/http/0.2.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/http/0.3.0/message.json" + } + } + ] + }, + "ws": {}, + "amqp": { + "properties": { + "bindingVersion": { + "enum": [ + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/amqp/0.3.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/amqp/0.3.0/message.json" + } + } + ] + }, + "amqp1": {}, + "mqtt": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/mqtt/0.2.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/mqtt/0.2.0/message.json" + } + } + ] + }, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.4.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.3.0/message.json" + } + } + ] + }, + "anypointmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/anypointmq/0.0.1/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/anypointmq/0.0.1/message.json" + } + } + ] + }, + "nats": {}, + "jms": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/message.json" + } + } + ] + }, + "sns": {}, + "sqs": {}, + "stomp": {}, + "redis": {}, + "ibmmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/ibmmq/0.1.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/ibmmq/0.1.0/message.json" + } + } + ] + }, + "solace": {}, + "googlepubsub": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/googlepubsub/0.2.0/message.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/googlepubsub/0.2.0/message.json" + } + } + ] + } + } + }, + "http://asyncapi.com/bindings/http/0.3.0/message.json": { + "$id": "http://asyncapi.com/bindings/http/0.3.0/message.json", + "title": "HTTP message bindings object", + "description": "This object contains information about the message representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "headers": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "\tA Schema object containing the definitions for HTTP-specific headers. This schema MUST be of type 'object' and have a 'properties' key." + }, + "statusCode": { + "type": "number", + "description": "The HTTP response status code according to [RFC 9110](https://httpwg.org/specs/rfc9110.html#overview.of.status.codes). `statusCode` is only relevant for messages referenced by the [Operation Reply Object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#operationReplyObject), as it defines the status code for the response. In all other cases, this value can be safely ignored." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "properties": { + "Content-Type": { + "type": "string", + "enum": [ + "application/json" + ] + } + } + }, + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/http/0.2.0/message.json": { + "$id": "http://asyncapi.com/bindings/http/0.2.0/message.json", + "title": "HTTP message bindings object", + "description": "This object contains information about the message representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "headers": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "\tA Schema object containing the definitions for HTTP-specific headers. This schema MUST be of type 'object' and have a 'properties' key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "properties": { + "Content-Type": { + "type": "string", + "enum": [ + "application/json" + ] + } + } + }, + "bindingVersion": "0.2.0" + } + ] + }, + "http://asyncapi.com/bindings/amqp/0.3.0/message.json": { + "$id": "http://asyncapi.com/bindings/amqp/0.3.0/message.json", + "title": "AMQP message bindings object", + "description": "This object contains information about the message representation in AMQP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "contentEncoding": { + "type": "string", + "description": "A MIME encoding for the message content." + }, + "messageType": { + "type": "string", + "description": "Application-specific message type." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "contentEncoding": "gzip", + "messageType": "user.signup", + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/mqtt/0.2.0/message.json": { + "$id": "http://asyncapi.com/bindings/mqtt/0.2.0/message.json", + "title": "MQTT message bindings object", + "description": "This object contains information about the message representation in MQTT.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "payloadFormatIndicator": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "description": "1 indicates that the payload is UTF-8 encoded character data. 0 indicates that the payload format is unspecified.", + "default": 0 + }, + "correlationData": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "Correlation Data is used by the sender of the request message to identify which request the response message is for when it is received." + }, + "contentType": { + "type": "string", + "description": "String describing the content type of the message payload. This should not conflict with the contentType field of the associated AsyncAPI Message object." + }, + "responseTopic": { + "oneOf": [ + { + "type": "string", + "format": "uri-template", + "minLength": 1 + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "The topic (channel URI) to be used for a response message." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "bindingVersion": "0.2.0" + }, + { + "contentType": "application/json", + "correlationData": { + "type": "string", + "format": "uuid" + }, + "responseTopic": "application/responses", + "bindingVersion": "0.2.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.5.0/message.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.5.0/message.json", + "title": "Message Schema", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "key": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + } + ], + "description": "The message key." + }, + "schemaIdLocation": { + "type": "string", + "description": "If a Schema Registry is used when performing this operation, tells where the id of schema is stored.", + "enum": [ + "header", + "payload" + ] + }, + "schemaIdPayloadEncoding": { + "type": "string", + "description": "Number of bytes or vendor specific values when schema id is encoded in payload." + }, + "schemaLookupStrategy": { + "type": "string", + "description": "Freeform string for any naming strategy class to use. Clients should default to the vendor default if not supplied." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "key": { + "type": "string", + "enum": [ + "myKey" + ] + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "apicurio-new", + "schemaLookupStrategy": "TopicIdStrategy", + "bindingVersion": "0.5.0" + }, + { + "key": { + "$ref": "path/to/user-create.avsc#/UserCreate" + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "4", + "bindingVersion": "0.5.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.4.0/message.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.4.0/message.json", + "title": "Message Schema", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "key": { + "anyOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/avroSchema_v1.json" + } + ], + "description": "The message key." + }, + "schemaIdLocation": { + "type": "string", + "description": "If a Schema Registry is used when performing this operation, tells where the id of schema is stored.", + "enum": [ + "header", + "payload" + ] + }, + "schemaIdPayloadEncoding": { + "type": "string", + "description": "Number of bytes or vendor specific values when schema id is encoded in payload." + }, + "schemaLookupStrategy": { + "type": "string", + "description": "Freeform string for any naming strategy class to use. Clients should default to the vendor default if not supplied." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "key": { + "type": "string", + "enum": [ + "myKey" + ] + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "apicurio-new", + "schemaLookupStrategy": "TopicIdStrategy", + "bindingVersion": "0.4.0" + }, + { + "key": { + "$ref": "path/to/user-create.avsc#/UserCreate" + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "4", + "bindingVersion": "0.4.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.3.0/message.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.3.0/message.json", + "title": "Message Schema", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "key": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "The message key." + }, + "schemaIdLocation": { + "type": "string", + "description": "If a Schema Registry is used when performing this operation, tells where the id of schema is stored.", + "enum": [ + "header", + "payload" + ] + }, + "schemaIdPayloadEncoding": { + "type": "string", + "description": "Number of bytes or vendor specific values when schema id is encoded in payload." + }, + "schemaLookupStrategy": { + "type": "string", + "description": "Freeform string for any naming strategy class to use. Clients should default to the vendor default if not supplied." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "key": { + "type": "string", + "enum": [ + "myKey" + ] + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "apicurio-new", + "schemaLookupStrategy": "TopicIdStrategy", + "bindingVersion": "0.3.0" + }, + { + "key": { + "$ref": "path/to/user-create.avsc#/UserCreate" + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "4", + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/anypointmq/0.0.1/message.json": { + "$id": "http://asyncapi.com/bindings/anypointmq/0.0.1/message.json", + "title": "Anypoint MQ message bindings object", + "description": "This object contains configuration for describing an Anypoint MQ message as an AsyncAPI message. This objects only contains configuration that can not be provided in the AsyncAPI standard message object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "headers": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "A Schema object containing the definitions for Anypoint MQ-specific headers (protocol headers). This schema MUST be of type 'object' and have a 'properties' key. Examples of Anypoint MQ protocol headers are 'messageId' and 'messageGroupId'." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + } + } + }, + "bindingVersion": "0.0.1" + } + ] + }, + "http://asyncapi.com/bindings/jms/0.0.1/message.json": { + "$id": "http://asyncapi.com/bindings/jms/0.0.1/message.json", + "title": "Message Schema", + "description": "This object contains configuration for describing a JMS message as an AsyncAPI message. This objects only contains configuration that can not be provided in the AsyncAPI standard message object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "headers": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "A Schema object containing the definitions for JMS headers (protocol headers). This schema MUST be of type 'object' and have a 'properties' key. Examples of JMS protocol headers are 'JMSMessageID', 'JMSTimestamp', and 'JMSCorrelationID'." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "required": [ + "JMSMessageID" + ], + "properties": { + "JMSMessageID": { + "type": [ + "string", + "null" + ], + "description": "A unique message identifier. This may be set by your JMS Provider on your behalf." + }, + "JMSTimestamp": { + "type": "integer", + "description": "The time the message was sent. This may be set by your JMS Provider on your behalf. The time the message was sent. The value of the timestamp is the amount of time, measured in milliseconds, that has elapsed since midnight, January 1, 1970, UTC." + }, + "JMSDeliveryMode": { + "type": "string", + "enum": [ + "PERSISTENT", + "NON_PERSISTENT" + ], + "default": "PERSISTENT", + "description": "Denotes the delivery mode for the message. This may be set by your JMS Provider on your behalf." + }, + "JMSPriority": { + "type": "integer", + "default": 4, + "description": "The priority of the message. This may be set by your JMS Provider on your behalf." + }, + "JMSExpires": { + "type": "integer", + "description": "The time at which the message expires. This may be set by your JMS Provider on your behalf. A value of zero means that the message does not expire. Any non-zero value is the amount of time, measured in milliseconds, that has elapsed since midnight, January 1, 1970, UTC, at which the message will expire." + }, + "JMSType": { + "type": [ + "string", + "null" + ], + "description": "The type of message. Some JMS providers use a message repository that contains the definitions of messages sent by applications. The 'JMSType' header field may reference a message's definition in the provider's repository. The JMS API does not define a standard message definition repository, nor does it define a naming policy for the definitions it contains. Some messaging systems require that a message type definition for each application message be created and that each message specify its type. In order to work with such JMS providers, JMS clients should assign a value to 'JMSType', whether the application makes use of it or not. This ensures that the field is properly set for those providers that require it." + }, + "JMSCorrelationID": { + "type": [ + "string", + "null" + ], + "description": "The correlation identifier of the message. A client can use the 'JMSCorrelationID' header field to link one message with another. A typical use is to link a response message with its request message. Since each message sent by a JMS provider is assigned a message ID value, it is convenient to link messages via message ID, such message ID values must start with the 'ID:' prefix. Conversely, application-specified values must not start with the 'ID:' prefix; this is reserved for provider-generated message ID values." + }, + "JMSReplyTo": { + "type": "string", + "description": "The queue or topic that the message sender expects replies to." + } + } + }, + "bindingVersion": "0.0.1" + } + ] + }, + "http://asyncapi.com/bindings/ibmmq/0.1.0/message.json": { + "$id": "http://asyncapi.com/bindings/ibmmq/0.1.0/message.json", + "title": "IBM MQ message bindings object", + "description": "This object contains information about the message representation in IBM MQ.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "jms", + "binary" + ], + "default": "string", + "description": "The type of the message." + }, + "headers": { + "type": "string", + "description": "Defines the IBM MQ message headers to include with this message. More than one header can be specified as a comma separated list. Supporting information on IBM MQ message formats can be found on this [page](https://www.ibm.com/docs/en/ibm-mq/9.2?topic=mqmd-format-mqchar8) in the IBM MQ Knowledge Center." + }, + "description": { + "type": "string", + "description": "Provides additional information for application developers: describes the message type or format." + }, + "expiry": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "The recommended setting the client should use for the TTL (Time-To-Live) of the message. This is a period of time expressed in milliseconds and set by the application that puts the message. 'expiry' values are API dependant e.g., MQI and JMS use different units of time and default values for 'unlimited'. General information on IBM MQ message expiry can be found on this [page](https://www.ibm.com/docs/en/ibm-mq/9.2?topic=mqmd-expiry-mqlong) in the IBM MQ Knowledge Center." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding." + } + }, + "oneOf": [ + { + "properties": { + "type": { + "const": "binary" + } + } + }, + { + "properties": { + "type": { + "const": "jms" + } + }, + "not": { + "required": [ + "headers" + ] + } + }, + { + "properties": { + "type": { + "const": "string" + } + }, + "not": { + "required": [ + "headers" + ] + } + } + ], + "examples": [ + { + "type": "string", + "bindingVersion": "0.1.0" + }, + { + "type": "jms", + "description": "JMS stream message", + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/bindings/googlepubsub/0.2.0/message.json": { + "$id": "http://asyncapi.com/bindings/googlepubsub/0.2.0/message.json", + "title": "Cloud Pub/Sub Channel Schema", + "description": "This object contains information about the message representation for Google Cloud Pub/Sub.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding." + }, + "attributes": { + "type": "object" + }, + "orderingKey": { + "type": "string" + }, + "schema": { + "type": "object", + "additionalItems": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "examples": [ + { + "schema": { + "name": "projects/your-project-id/schemas/your-avro-schema-id" + } + }, + { + "schema": { + "name": "projects/your-project-id/schemas/your-protobuf-schema-id" + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/messageTrait.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/messageTrait.json", + "type": "object", + "description": "Describes a trait that MAY be applied to a Message Object. This object MAY contain any property from the Message Object, except payload and traits.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "contentType": { + "type": "string", + "description": "The content type to use when encoding/decoding a message's payload. The value MUST be a specific media type (e.g. application/json). When omitted, the value MUST be the one specified on the defaultContentType field." + }, + "headers": { + "$ref": "http://asyncapi.com/definitions/3.0.0/anySchema.json" + }, + "correlationId": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/correlationId.json" + } + ] + }, + "tags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "description": "List of examples.", + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageExampleObject.json" + } + }, + "bindings": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageBindingsObject.json" + } + ] + } + }, + "examples": [ + { + "contentType": "application/json" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/parameters.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/parameters.json", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/parameter.json" + } + ] + }, + "description": "JSON objects describing re-usable channel parameters.", + "examples": [ + { + "address": "user/{userId}/signedup", + "parameters": { + "userId": { + "description": "Id of the user." + } + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/parameter.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/parameter.json", + "description": "Describes a parameter included in a channel address.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "enum": { + "description": "An enumeration of string values to be used if the substitution options are from a limited set.", + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "description": "The default value to use for substitution, and to send, if an alternate value is not supplied.", + "type": "string" + }, + "examples": { + "description": "An array of examples of the parameter value.", + "type": "array", + "items": { + "type": "string" + } + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the parameter value", + "pattern": "^\\$message\\.(header|payload)#(\\/(([^\\/~])|(~[01]))*)*" + } + }, + "examples": [ + { + "address": "user/{userId}/signedup", + "parameters": { + "userId": { + "description": "Id of the user.", + "location": "$message.payload#/user/id" + } + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/channelBindingsObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/channelBindingsObject.json", + "type": "object", + "description": "Map describing protocol-specific definitions for a channel.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "http": {}, + "ws": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/websockets/0.1.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/websockets/0.1.0/channel.json" + } + } + ] + }, + "amqp": { + "properties": { + "bindingVersion": { + "enum": [ + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/amqp/0.3.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/amqp/0.3.0/channel.json" + } + } + ] + }, + "amqp1": {}, + "mqtt": {}, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.4.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.3.0/channel.json" + } + } + ] + }, + "anypointmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/anypointmq/0.0.1/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/anypointmq/0.0.1/channel.json" + } + } + ] + }, + "nats": {}, + "jms": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/jms/0.0.1/channel.json" + } + } + ] + }, + "sns": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/channel.json" + } + } + ] + }, + "sqs": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json" + } + } + ] + }, + "stomp": {}, + "redis": {}, + "ibmmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/ibmmq/0.1.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/ibmmq/0.1.0/channel.json" + } + } + ] + }, + "solace": {}, + "googlepubsub": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/googlepubsub/0.2.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/googlepubsub/0.2.0/channel.json" + } + } + ] + }, + "pulsar": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/pulsar/0.1.0/channel.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/pulsar/0.1.0/channel.json" + } + } + ] + } + } + }, + "http://asyncapi.com/bindings/websockets/0.1.0/channel.json": { + "$id": "http://asyncapi.com/bindings/websockets/0.1.0/channel.json", + "title": "WebSockets channel bindings object", + "description": "When using WebSockets, the channel represents the connection. Unlike other protocols that support multiple virtual channels (topics, routing keys, etc.) per connection, WebSockets doesn't support virtual channels or, put it another way, there's only one channel and its characteristics are strongly related to the protocol used for the handshake, i.e., HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "POST" + ], + "description": "The HTTP method to use when establishing the connection. Its value MUST be either 'GET' or 'POST'." + }, + "query": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "A Schema object containing the definitions for each query parameter. This schema MUST be of type 'object' and have a 'properties' key." + }, + "headers": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "A Schema object containing the definitions of the HTTP headers to use when establishing the connection. This schema MUST be of type 'object' and have a 'properties' key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "method": "POST", + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/bindings/amqp/0.3.0/channel.json": { + "$id": "http://asyncapi.com/bindings/amqp/0.3.0/channel.json", + "title": "AMQP channel bindings object", + "description": "This object contains information about the channel representation in AMQP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "is": { + "type": "string", + "enum": [ + "queue", + "routingKey" + ], + "description": "Defines what type of channel is it. Can be either 'queue' or 'routingKey' (default)." + }, + "exchange": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "The name of the exchange. It MUST NOT exceed 255 characters long." + }, + "type": { + "type": "string", + "enum": [ + "topic", + "direct", + "fanout", + "default", + "headers" + ], + "description": "The type of the exchange. Can be either 'topic', 'direct', 'fanout', 'default' or 'headers'." + }, + "durable": { + "type": "boolean", + "description": "Whether the exchange should survive broker restarts or not." + }, + "autoDelete": { + "type": "boolean", + "description": "Whether the exchange should be deleted when the last queue is unbound from it." + }, + "vhost": { + "type": "string", + "default": "/", + "description": "The virtual host of the exchange. Defaults to '/'." + } + }, + "description": "When is=routingKey, this object defines the exchange properties." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "The name of the queue. It MUST NOT exceed 255 characters long." + }, + "durable": { + "type": "boolean", + "description": "Whether the queue should survive broker restarts or not." + }, + "exclusive": { + "type": "boolean", + "description": "Whether the queue should be used only by one connection or not." + }, + "autoDelete": { + "type": "boolean", + "description": "Whether the queue should be deleted when the last consumer unsubscribes." + }, + "vhost": { + "type": "string", + "default": "/", + "description": "The virtual host of the queue. Defaults to '/'." + } + }, + "description": "When is=queue, this object defines the queue properties." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "oneOf": [ + { + "properties": { + "is": { + "const": "routingKey" + } + }, + "required": [ + "exchange" + ], + "not": { + "required": [ + "queue" + ] + } + }, + { + "properties": { + "is": { + "const": "queue" + } + }, + "required": [ + "queue" + ], + "not": { + "required": [ + "exchange" + ] + } + } + ], + "examples": [ + { + "is": "routingKey", + "exchange": { + "name": "myExchange", + "type": "topic", + "durable": true, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + }, + { + "is": "queue", + "queue": { + "name": "my-queue-name", + "durable": true, + "exclusive": true, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.5.0/channel.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.5.0/channel.json", + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "topic": { + "type": "string", + "description": "Kafka topic name if different from channel name." + }, + "partitions": { + "type": "integer", + "minimum": 1, + "description": "Number of partitions configured on this topic." + }, + "replicas": { + "type": "integer", + "minimum": 1, + "description": "Number of replicas configured on this topic." + }, + "topicConfiguration": { + "description": "Topic configuration properties that are relevant for the API.", + "type": "object", + "additionalProperties": true, + "properties": { + "cleanup.policy": { + "description": "The [`cleanup.policy`](https://kafka.apache.org/documentation/#topicconfigs_cleanup.policy) configuration option.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "compact", + "delete" + ] + } + }, + "retention.ms": { + "description": "The [`retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_retention.ms) configuration option.", + "type": "integer", + "minimum": -1 + }, + "retention.bytes": { + "description": "The [`retention.bytes`](https://kafka.apache.org/documentation/#topicconfigs_retention.bytes) configuration option.", + "type": "integer", + "minimum": -1 + }, + "delete.retention.ms": { + "description": "The [`delete.retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_delete.retention.ms) configuration option.", + "type": "integer", + "minimum": 0 + }, + "max.message.bytes": { + "description": "The [`max.message.bytes`](https://kafka.apache.org/documentation/#topicconfigs_max.message.bytes) configuration option.", + "type": "integer", + "minimum": 0 + }, + "confluent.key.schema.validation": { + "description": "It shows whether the schema validation for the message key is enabled. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-key-schema-validation)", + "type": "boolean" + }, + "confluent.key.subject.name.strategy": { + "description": "The name of the schema lookup strategy for the message key. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-key-subject-name-strategy)", + "type": "string" + }, + "confluent.value.schema.validation": { + "description": "It shows whether the schema validation for the message value is enabled. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-value-schema-validation)", + "type": "boolean" + }, + "confluent.value.subject.name.strategy": { + "description": "The name of the schema lookup strategy for the message value. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-value-subject-name-strategy)", + "type": "string" + } + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "topic": "my-specific-topic", + "partitions": 20, + "replicas": 3, + "bindingVersion": "0.5.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.4.0/channel.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.4.0/channel.json", + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "topic": { + "type": "string", + "description": "Kafka topic name if different from channel name." + }, + "partitions": { + "type": "integer", + "minimum": 1, + "description": "Number of partitions configured on this topic." + }, + "replicas": { + "type": "integer", + "minimum": 1, + "description": "Number of replicas configured on this topic." + }, + "topicConfiguration": { + "description": "Topic configuration properties that are relevant for the API.", + "type": "object", + "additionalProperties": false, + "properties": { + "cleanup.policy": { + "description": "The [`cleanup.policy`](https://kafka.apache.org/documentation/#topicconfigs_cleanup.policy) configuration option.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "compact", + "delete" + ] + } + }, + "retention.ms": { + "description": "The [`retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_retention.ms) configuration option.", + "type": "integer", + "minimum": -1 + }, + "retention.bytes": { + "description": "The [`retention.bytes`](https://kafka.apache.org/documentation/#topicconfigs_retention.bytes) configuration option.", + "type": "integer", + "minimum": -1 + }, + "delete.retention.ms": { + "description": "The [`delete.retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_delete.retention.ms) configuration option.", + "type": "integer", + "minimum": 0 + }, + "max.message.bytes": { + "description": "The [`max.message.bytes`](https://kafka.apache.org/documentation/#topicconfigs_max.message.bytes) configuration option.", + "type": "integer", + "minimum": 0 + } + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "topic": "my-specific-topic", + "partitions": 20, + "replicas": 3, + "bindingVersion": "0.4.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.3.0/channel.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.3.0/channel.json", + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "topic": { + "type": "string", + "description": "Kafka topic name if different from channel name." + }, + "partitions": { + "type": "integer", + "minimum": 1, + "description": "Number of partitions configured on this topic." + }, + "replicas": { + "type": "integer", + "minimum": 1, + "description": "Number of replicas configured on this topic." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "topic": "my-specific-topic", + "partitions": 20, + "replicas": 3, + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/anypointmq/0.0.1/channel.json": { + "$id": "http://asyncapi.com/bindings/anypointmq/0.0.1/channel.json", + "title": "Anypoint MQ channel bindings object", + "description": "This object contains configuration for describing an Anypoint MQ exchange, queue, or FIFO queue as an AsyncAPI channel. This objects only contains configuration that can not be provided in the AsyncAPI standard channel object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "destination": { + "type": "string", + "description": "The destination (queue or exchange) name for this channel. SHOULD only be specified if the channel name differs from the actual destination name, such as when the channel name is not a valid destination name in Anypoint MQ. Defaults to the channel name." + }, + "destinationType": { + "type": "string", + "enum": [ + "exchange", + "queue", + "fifo-queue" + ], + "default": "queue", + "description": "The type of destination. SHOULD be specified to document the messaging model (publish/subscribe, point-to-point, strict message ordering) supported by this channel." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "destination": "user-signup-exchg", + "destinationType": "exchange", + "bindingVersion": "0.0.1" + } + ] + }, + "http://asyncapi.com/bindings/jms/0.0.1/channel.json": { + "$id": "http://asyncapi.com/bindings/jms/0.0.1/channel.json", + "title": "Channel Schema", + "description": "This object contains configuration for describing a JMS queue, or FIFO queue as an AsyncAPI channel. This objects only contains configuration that can not be provided in the AsyncAPI standard channel object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "destination": { + "type": "string", + "description": "The destination (queue) name for this channel. SHOULD only be specified if the channel name differs from the actual destination name, such as when the channel name is not a valid destination name according to the JMS Provider. Defaults to the channel name." + }, + "destinationType": { + "type": "string", + "enum": [ + "queue", + "fifo-queue" + ], + "default": "queue", + "description": "The type of destination. SHOULD be specified to document the messaging model (point-to-point, or strict message ordering) supported by this channel." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "destination": "user-signed-up", + "destinationType": "fifo-queue", + "bindingVersion": "0.0.1" + } + ] + }, + "http://asyncapi.com/bindings/sns/0.1.0/channel.json": { + "$id": "http://asyncapi.com/bindings/sns/0.1.0/channel.json", + "title": "Channel Schema", + "description": "This object contains information about the channel representation in SNS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "name": { + "type": "string", + "description": "The name of the topic. Can be different from the channel name to allow flexibility around AWS resource naming limitations." + }, + "ordering": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/ordering" + }, + "policy": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/policy" + }, + "tags": { + "type": "object", + "description": "Key-value pairs that represent AWS tags on the topic." + }, + "bindingVersion": { + "type": "string", + "description": "The version of this binding.", + "default": "latest" + } + }, + "required": [ + "name" + ], + "definitions": { + "ordering": { + "type": "object", + "description": "By default, we assume an unordered SNS topic. This field allows configuration of a FIFO SNS Topic.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "type": { + "type": "string", + "description": "Defines the type of SNS Topic.", + "enum": [ + "standard", + "FIFO" + ] + }, + "contentBasedDeduplication": { + "type": "boolean", + "description": "True to turn on de-duplication of messages for a channel." + } + }, + "required": [ + "type" + ] + }, + "policy": { + "type": "object", + "description": "The security policy for the SNS Topic.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "statements": { + "type": "array", + "description": "An array of statement objects, each of which controls a permission for this topic", + "items": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/channel.json#/definitions/statement" + } + } + }, + "required": [ + "statements" + ] + }, + "statement": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "principal": { + "description": "The AWS account or resource ARN that this statement applies to.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "action": { + "description": "The SNS permission being allowed or denied e.g. sns:Publish", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "effect", + "principal", + "action" + ] + } + }, + "examples": [ + { + "name": "my-sns-topic", + "policy": { + "statements": [ + { + "effect": "Allow", + "principal": "*", + "action": "SNS:Publish" + } + ] + } + } + ] + }, + "http://asyncapi.com/bindings/sqs/0.2.0/channel.json": { + "$id": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json", + "title": "Channel Schema", + "description": "This object contains information about the channel representation in SQS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "queue": { + "description": "A definition of the queue that will be used as the channel.", + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json#/definitions/queue" + }, + "deadLetterQueue": { + "description": "A definition of the queue that will be used for un-processable messages.", + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json#/definitions/queue" + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0", + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed.", + "default": "latest" + } + }, + "required": [ + "queue" + ], + "definitions": { + "queue": { + "type": "object", + "description": "A definition of a queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "name": { + "type": "string", + "description": "The name of the queue. When an SNS Operation Binding Object references an SQS queue by name, the identifier should be the one in this field." + }, + "fifoQueue": { + "type": "boolean", + "description": "Is this a FIFO queue?", + "default": false + }, + "deduplicationScope": { + "type": "string", + "enum": [ + "queue", + "messageGroup" + ], + "description": "Specifies whether message deduplication occurs at the message group or queue level. Valid values are messageGroup and queue (default).", + "default": "queue" + }, + "fifoThroughputLimit": { + "type": "string", + "enum": [ + "perQueue", + "perMessageGroupId" + ], + "description": "Specifies whether the FIFO queue throughput quota applies to the entire queue or per message group. Valid values are perQueue (default) and perMessageGroupId.", + "default": "perQueue" + }, + "deliveryDelay": { + "type": "integer", + "description": "The number of seconds to delay before a message sent to the queue can be received. used to create a delay queue.", + "minimum": 0, + "maximum": 900, + "default": 0 + }, + "visibilityTimeout": { + "type": "integer", + "description": "The length of time, in seconds, that a consumer locks a message - hiding it from reads - before it is unlocked and can be read again.", + "minimum": 0, + "maximum": 43200, + "default": 30 + }, + "receiveMessageWaitTime": { + "type": "integer", + "description": "Determines if the queue uses short polling or long polling. Set to zero the queue reads available messages and returns immediately. Set to a non-zero integer, long polling waits the specified number of seconds for messages to arrive before returning.", + "default": 0 + }, + "messageRetentionPeriod": { + "type": "integer", + "description": "How long to retain a message on the queue in seconds, unless deleted.", + "minimum": 60, + "maximum": 1209600, + "default": 345600 + }, + "redrivePolicy": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json#/definitions/redrivePolicy" + }, + "policy": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json#/definitions/policy" + }, + "tags": { + "type": "object", + "description": "Key-value pairs that represent AWS tags on the queue." + } + }, + "required": [ + "name", + "fifoQueue" + ] + }, + "redrivePolicy": { + "type": "object", + "description": "Prevent poison pill messages by moving un-processable messages to an SQS dead letter queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "deadLetterQueue": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json#/definitions/identifier" + }, + "maxReceiveCount": { + "type": "integer", + "description": "The number of times a message is delivered to the source queue before being moved to the dead-letter queue.", + "default": 10 + } + }, + "required": [ + "deadLetterQueue" + ] + }, + "identifier": { + "type": "object", + "description": "The SQS queue to use as a dead letter queue (DLQ).", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "arn": { + "type": "string", + "description": "The target is an ARN. For example, for SQS, the identifier may be an ARN, which will be of the form: arn:aws:sqs:{region}:{account-id}:{queueName}" + }, + "name": { + "type": "string", + "description": "The endpoint is identified by a name, which corresponds to an identifying field called 'name' of a binding for that protocol on this publish Operation Object. For example, if the protocol is 'sqs' then the name refers to the name field sqs binding." + } + } + }, + "policy": { + "type": "object", + "description": "The security policy for the SQS Queue", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "statements": { + "type": "array", + "description": "An array of statement objects, each of which controls a permission for this queue.", + "items": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/channel.json#/definitions/statement" + } + } + }, + "required": [ + "statements" + ] + }, + "statement": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "principal": { + "description": "The AWS account or resource ARN that this statement applies to.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "action": { + "description": "The SQS permission being allowed or denied e.g. sqs:ReceiveMessage", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "effect", + "principal", + "action" + ] + } + }, + "examples": [ + { + "queue": { + "name": "myQueue", + "fifoQueue": true, + "deduplicationScope": "messageGroup", + "fifoThroughputLimit": "perMessageGroupId", + "deliveryDelay": 15, + "visibilityTimeout": 60, + "receiveMessageWaitTime": 0, + "messageRetentionPeriod": 86400, + "redrivePolicy": { + "deadLetterQueue": { + "arn": "arn:aws:SQS:eu-west-1:0000000:123456789" + }, + "maxReceiveCount": 15 + }, + "policy": { + "statements": [ + { + "effect": "Deny", + "principal": "arn:aws:iam::123456789012:user/dec.kolakowski", + "action": [ + "sqs:SendMessage", + "sqs:ReceiveMessage" + ] + } + ] + }, + "tags": { + "owner": "AsyncAPI.NET", + "platform": "AsyncAPIOrg" + } + }, + "deadLetterQueue": { + "name": "myQueue_error", + "deliveryDelay": 0, + "visibilityTimeout": 0, + "receiveMessageWaitTime": 0, + "messageRetentionPeriod": 604800 + } + } + ] + }, + "http://asyncapi.com/bindings/ibmmq/0.1.0/channel.json": { + "$id": "http://asyncapi.com/bindings/ibmmq/0.1.0/channel.json", + "title": "IBM MQ channel bindings object", + "description": "This object contains information about the channel representation in IBM MQ. Each channel corresponds to a Queue or Topic within IBM MQ.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "destinationType": { + "type": "string", + "enum": [ + "topic", + "queue" + ], + "default": "topic", + "description": "Defines the type of AsyncAPI channel." + }, + "queue": { + "type": "object", + "description": "Defines the properties of a queue.", + "properties": { + "objectName": { + "type": "string", + "maxLength": 48, + "description": "Defines the name of the IBM MQ queue associated with the channel." + }, + "isPartitioned": { + "type": "boolean", + "default": false, + "description": "Defines if the queue is a cluster queue and therefore partitioned. If 'true', a binding option MAY be specified when accessing the queue. More information on binding options can be found on this page in the IBM MQ Knowledge Center." + }, + "exclusive": { + "type": "boolean", + "default": false, + "description": "Specifies if it is recommended to open the queue exclusively." + } + }, + "required": [ + "objectName" + ] + }, + "topic": { + "type": "object", + "description": "Defines the properties of a topic.", + "properties": { + "string": { + "type": "string", + "maxLength": 10240, + "description": "The value of the IBM MQ topic string to be used." + }, + "objectName": { + "type": "string", + "maxLength": 48, + "description": "The name of the IBM MQ topic object." + }, + "durablePermitted": { + "type": "boolean", + "default": true, + "description": "Defines if the subscription may be durable." + }, + "lastMsgRetained": { + "type": "boolean", + "default": false, + "description": "Defines if the last message published will be made available to new subscriptions." + } + } + }, + "maxMsgLength": { + "type": "integer", + "minimum": 0, + "maximum": 104857600, + "description": "The maximum length of the physical message (in bytes) accepted by the Topic or Queue. Messages produced that are greater in size than this value may fail to be delivered. More information on the maximum message length can be found on this [page](https://www.ibm.com/support/knowledgecenter/SSFKSJ_latest/com.ibm.mq.ref.dev.doc/q097520_.html) in the IBM MQ Knowledge Center." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding." + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "const": "topic" + } + }, + "not": { + "required": [ + "queue" + ] + } + }, + { + "properties": { + "destinationType": { + "const": "queue" + } + }, + "required": [ + "queue" + ], + "not": { + "required": [ + "topic" + ] + } + } + ], + "examples": [ + { + "destinationType": "topic", + "topic": { + "objectName": "myTopicName" + }, + "bindingVersion": "0.1.0" + }, + { + "destinationType": "queue", + "queue": { + "objectName": "myQueueName", + "exclusive": true + }, + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/bindings/googlepubsub/0.2.0/channel.json": { + "$id": "http://asyncapi.com/bindings/googlepubsub/0.2.0/channel.json", + "title": "Cloud Pub/Sub Channel Schema", + "description": "This object contains information about the channel representation for Google Cloud Pub/Sub.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding." + }, + "labels": { + "type": "object" + }, + "messageRetentionDuration": { + "type": "string" + }, + "messageStoragePolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowedPersistenceRegions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "schemaSettings": { + "type": "object", + "additionalItems": false, + "properties": { + "encoding": { + "type": "string" + }, + "firstRevisionId": { + "type": "string" + }, + "lastRevisionId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "encoding", + "name" + ] + } + }, + "required": [ + "schemaSettings" + ], + "examples": [ + { + "labels": { + "label1": "value1", + "label2": "value2" + }, + "messageRetentionDuration": "86400s", + "messageStoragePolicy": { + "allowedPersistenceRegions": [ + "us-central1", + "us-east1" + ] + }, + "schemaSettings": { + "encoding": "json", + "name": "projects/your-project-id/schemas/your-schema" + } + } + ] + }, + "http://asyncapi.com/bindings/pulsar/0.1.0/channel.json": { + "$id": "http://asyncapi.com/bindings/pulsar/0.1.0/channel.json", + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Pulsar, which covers namespace and topic level admin configuration. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "required": [ + "namespace", + "persistence" + ], + "properties": { + "namespace": { + "type": "string", + "description": "The namespace, the channel is associated with." + }, + "persistence": { + "type": "string", + "enum": [ + "persistent", + "non-persistent" + ], + "description": "persistence of the topic in Pulsar." + }, + "compaction": { + "type": "integer", + "minimum": 0, + "description": "Topic compaction threshold given in MB" + }, + "geo-replication": { + "type": "array", + "description": "A list of clusters the topic is replicated to.", + "items": { + "type": "string" + } + }, + "retention": { + "type": "object", + "additionalProperties": false, + "properties": { + "time": { + "type": "integer", + "minimum": 0, + "description": "Time given in Minutes. `0` = Disable message retention." + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "Size given in MegaBytes. `0` = Disable message retention." + } + } + }, + "ttl": { + "type": "integer", + "description": "TTL in seconds for the specified topic" + }, + "deduplication": { + "type": "boolean", + "description": "Whether deduplication of events is enabled or not." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "namespace": "ns1", + "persistence": "persistent", + "compaction": 1000, + "retention": { + "time": 15, + "size": 1000 + }, + "ttl": 360, + "geo-replication": [ + "us-west", + "us-east" + ], + "deduplication": true, + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/operations.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/operations.json", + "type": "object", + "description": "Holds a dictionary with all the operations this application MUST implement.", + "additionalProperties": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json" + } + ] + }, + "examples": [ + { + "onUserSignUp": { + "title": "User sign up", + "summary": "Action to sign a user up.", + "description": "A longer description", + "channel": { + "$ref": "#/channels/userSignup" + }, + "action": "send", + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + }, + { + "name": "register" + } + ], + "bindings": { + "amqp": { + "ack": false + } + }, + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ] + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/operation.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/operation.json", + "type": "object", + "description": "Describes a specific operation.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "required": [ + "action", + "channel" + ], + "properties": { + "action": { + "type": "string", + "description": "Allowed values are send and receive. Use send when it's expected that the application will send a message to the given channel, and receive when the application should expect receiving messages from the given channel.", + "enum": [ + "send", + "receive" + ] + }, + "channel": { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + "messages": { + "type": "array", + "description": "A list of $ref pointers pointing to the supported Message Objects that can be processed by this operation. It MUST contain a subset of the messages defined in the channel referenced in this operation. Every message processed by this operation MUST be valid against one, and only one, of the message objects referenced in this list. Please note the messages property value MUST be a list of Reference Objects and, therefore, MUST NOT contain Message Objects. However, it is RECOMMENDED that parsers (or other software) dereference this property for a better development experience.", + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + }, + "reply": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationReply.json" + } + ] + }, + "traits": { + "type": "array", + "description": "A list of traits to apply to the operation object. Traits MUST be merged using traits merge mechanism. The resulting object MUST be a valid Operation Object.", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationTrait.json" + } + ] + } + }, + "title": { + "type": "string", + "description": "A human-friendly title for the operation." + }, + "summary": { + "type": "string", + "description": "A brief summary of the operation." + }, + "description": { + "type": "string", + "description": "A longer description of the operation. CommonMark is allowed." + }, + "security": { + "$ref": "http://asyncapi.com/definitions/3.0.0/securityRequirements.json" + }, + "tags": { + "type": "array", + "description": "A list of tags for logical grouping and categorization of operations.", + "items": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + }, + "bindings": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationBindingsObject.json" + } + ] + } + }, + "examples": [ + { + "title": "User sign up", + "summary": "Action to sign a user up.", + "description": "A longer description", + "channel": { + "$ref": "#/channels/userSignup" + }, + "action": "send", + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + }, + { + "name": "register" + } + ], + "bindings": { + "amqp": { + "ack": false + } + }, + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "messages": [ + { + "$ref": "/components/messages/userSignedUp" + } + ], + "reply": { + "address": { + "location": "$message.header#/replyTo" + }, + "channel": { + "$ref": "#/channels/userSignupReply" + }, + "messages": [ + { + "$ref": "/components/messages/userSignedUpReply" + } + ] + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/operationReply.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/operationReply.json", + "type": "object", + "description": "Describes the reply part that MAY be applied to an Operation Object. If an operation implements the request/reply pattern, the reply object represents the response message.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "address": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationReplyAddress.json" + } + ] + }, + "channel": { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + "messages": { + "type": "array", + "description": "A list of $ref pointers pointing to the supported Message Objects that can be processed by this operation as reply. It MUST contain a subset of the messages defined in the channel referenced in this operation reply. Every message processed by this operation MUST be valid against one, and only one, of the message objects referenced in this list. Please note the messages property value MUST be a list of Reference Objects and, therefore, MUST NOT contain Message Objects. However, it is RECOMMENDED that parsers (or other software) dereference this property for a better development experience.", + "items": { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + } + } + }, + "http://asyncapi.com/definitions/3.0.0/operationReplyAddress.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/operationReplyAddress.json", + "type": "object", + "description": "An object that specifies where an operation has to send the reply", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "required": [ + "location" + ], + "properties": { + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the reply address.", + "pattern": "^\\$message\\.(header|payload)#(\\/(([^\\/~])|(~[01]))*)*" + }, + "description": { + "type": "string", + "description": "An optional description of the address. CommonMark is allowed." + } + }, + "examples": [ + { + "description": "Consumer inbox", + "location": "$message.header#/replyTo" + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/operationTrait.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/operationTrait.json", + "type": "object", + "description": "Describes a trait that MAY be applied to an Operation Object. This object MAY contain any property from the Operation Object, except the action, channel and traits ones.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "title": { + "description": "A human-friendly title for the operation.", + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json#/properties/title" + }, + "summary": { + "description": "A short summary of what the operation is about.", + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json#/properties/summary" + }, + "description": { + "description": "A verbose explanation of the operation. CommonMark syntax can be used for rich text representation.", + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json#/properties/description" + }, + "security": { + "description": "A declaration of which security schemes are associated with this operation. Only one of the security scheme objects MUST be satisfied to authorize an operation. In cases where Server Security also applies, it MUST also be satisfied.", + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json#/properties/security" + }, + "tags": { + "description": "A list of tags for logical grouping and categorization of operations.", + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json#/properties/tags" + }, + "externalDocs": { + "description": "Additional external documentation for this operation.", + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json#/properties/externalDocs" + }, + "bindings": { + "description": "A map where the keys describe the name of the protocol and the values describe protocol-specific definitions for the operation.", + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationBindingsObject.json" + } + ] + } + }, + "examples": [ + { + "bindings": { + "amqp": { + "ack": false + } + } + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/operationBindingsObject.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/operationBindingsObject.json", + "type": "object", + "description": "Map describing protocol-specific definitions for an operation.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "http": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/http/0.3.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/http/0.2.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/http/0.3.0/operation.json" + } + } + ] + }, + "ws": {}, + "amqp": { + "properties": { + "bindingVersion": { + "enum": [ + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/amqp/0.3.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/amqp/0.3.0/operation.json" + } + } + ] + }, + "amqp1": {}, + "mqtt": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/mqtt/0.2.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/mqtt/0.2.0/operation.json" + } + } + ] + }, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.5.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.4.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/kafka/0.3.0/operation.json" + } + } + ] + }, + "anypointmq": {}, + "nats": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/nats/0.1.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/nats/0.1.0/operation.json" + } + } + ] + }, + "jms": {}, + "sns": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json" + } + } + ] + }, + "sqs": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json" + } + } + ] + }, + "stomp": {}, + "redis": {}, + "ibmmq": {}, + "solace": { + "properties": { + "bindingVersion": { + "enum": [ + "0.4.0", + "0.3.0", + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.4.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.4.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.3.0/operation.json" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "http://asyncapi.com/bindings/solace/0.2.0/operation.json" + } + } + ] + }, + "googlepubsub": {} + } + }, + "http://asyncapi.com/bindings/http/0.3.0/operation.json": { + "$id": "http://asyncapi.com/bindings/http/0.3.0/operation.json", + "title": "HTTP operation bindings object", + "description": "This object contains information about the operation representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "PUT", + "POST", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "When 'type' is 'request', this is the HTTP method, otherwise it MUST be ignored. Its value MUST be one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'CONNECT', and 'TRACE'." + }, + "query": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "A Schema object containing the definitions for each query parameter. This schema MUST be of type 'object' and have a properties key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.3.0" + }, + { + "method": "GET", + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/http/0.2.0/operation.json": { + "$id": "http://asyncapi.com/bindings/http/0.2.0/operation.json", + "title": "HTTP operation bindings object", + "description": "This object contains information about the operation representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "PUT", + "POST", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "When 'type' is 'request', this is the HTTP method, otherwise it MUST be ignored. Its value MUST be one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'CONNECT', and 'TRACE'." + }, + "query": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "A Schema object containing the definitions for each query parameter. This schema MUST be of type 'object' and have a properties key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.2.0" + }, + { + "method": "GET", + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.2.0" + } + ] + }, + "http://asyncapi.com/bindings/amqp/0.3.0/operation.json": { + "$id": "http://asyncapi.com/bindings/amqp/0.3.0/operation.json", + "title": "AMQP operation bindings object", + "description": "This object contains information about the operation representation in AMQP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "expiration": { + "type": "integer", + "minimum": 0, + "description": "TTL (Time-To-Live) for the message. It MUST be greater than or equal to zero." + }, + "userId": { + "type": "string", + "description": "Identifies the user who has sent the message." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The routing keys the message should be routed to at the time of publishing." + }, + "priority": { + "type": "integer", + "description": "A priority for the message." + }, + "deliveryMode": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "description": "Delivery mode of the message. Its value MUST be either 1 (transient) or 2 (persistent)." + }, + "mandatory": { + "type": "boolean", + "description": "Whether the message is mandatory or not." + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Like cc but consumers will not receive this information." + }, + "timestamp": { + "type": "boolean", + "description": "Whether the message should include a timestamp or not." + }, + "ack": { + "type": "boolean", + "description": "Whether the consumer should ack the message or not." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "expiration": 100000, + "userId": "guest", + "cc": [ + "user.logs" + ], + "priority": 10, + "deliveryMode": 2, + "mandatory": false, + "bcc": [ + "external.audit" + ], + "timestamp": true, + "ack": false, + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/mqtt/0.2.0/operation.json": { + "$id": "http://asyncapi.com/bindings/mqtt/0.2.0/operation.json", + "title": "MQTT operation bindings object", + "description": "This object contains information about the operation representation in MQTT.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "qos": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "Defines the Quality of Service (QoS) levels for the message flow between client and server. Its value MUST be either 0 (At most once delivery), 1 (At least once delivery), or 2 (Exactly once delivery)." + }, + "retain": { + "type": "boolean", + "description": "Whether the broker should retain the message or not." + }, + "messageExpiryInterval": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + } + ], + "description": "Lifetime of the message in seconds" + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "qos": 2, + "retain": true, + "messageExpiryInterval": 60, + "bindingVersion": "0.2.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.5.0/operation.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.5.0/operation.json", + "title": "Operation Schema", + "description": "This object contains information about the operation representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "groupId": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "Id of the consumer group." + }, + "clientId": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "Id of the consumer inside a consumer group." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "groupId": { + "type": "string", + "enum": [ + "myGroupId" + ] + }, + "clientId": { + "type": "string", + "enum": [ + "myClientId" + ] + }, + "bindingVersion": "0.5.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.4.0/operation.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.4.0/operation.json", + "title": "Operation Schema", + "description": "This object contains information about the operation representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "groupId": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "Id of the consumer group." + }, + "clientId": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "Id of the consumer inside a consumer group." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "groupId": { + "type": "string", + "enum": [ + "myGroupId" + ] + }, + "clientId": { + "type": "string", + "enum": [ + "myClientId" + ] + }, + "bindingVersion": "0.4.0" + } + ] + }, + "http://asyncapi.com/bindings/kafka/0.3.0/operation.json": { + "$id": "http://asyncapi.com/bindings/kafka/0.3.0/operation.json", + "title": "Operation Schema", + "description": "This object contains information about the operation representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "groupId": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "Id of the consumer group." + }, + "clientId": { + "$ref": "http://asyncapi.com/definitions/3.0.0/schema.json", + "description": "Id of the consumer inside a consumer group." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "groupId": { + "type": "string", + "enum": [ + "myGroupId" + ] + }, + "clientId": { + "type": "string", + "enum": [ + "myClientId" + ] + }, + "bindingVersion": "0.3.0" + } + ] + }, + "http://asyncapi.com/bindings/nats/0.1.0/operation.json": { + "$id": "http://asyncapi.com/bindings/nats/0.1.0/operation.json", + "title": "NATS operation bindings object", + "description": "This object contains information about the operation representation in NATS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "queue": { + "type": "string", + "description": "Defines the name of the queue to use. It MUST NOT exceed 255 characters.", + "maxLength": 255 + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "queue": "MyCustomQueue", + "bindingVersion": "0.1.0" + } + ] + }, + "http://asyncapi.com/bindings/sns/0.1.0/operation.json": { + "$id": "http://asyncapi.com/bindings/sns/0.1.0/operation.json", + "title": "Operation Schema", + "description": "This object contains information about the operation representation in SNS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "topic": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/identifier", + "description": "Often we can assume that the SNS Topic is the channel name-we provide this field in case the you need to supply the ARN, or the Topic name is not the channel name in the AsyncAPI document." + }, + "consumers": { + "type": "array", + "description": "The protocols that listen to this topic and their endpoints.", + "items": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/consumer" + }, + "minItems": 1 + }, + "deliveryPolicy": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/deliveryPolicy", + "description": "Policy for retries to HTTP. The field is the default for HTTP receivers of the SNS Topic which may be overridden by a specific consumer." + }, + "bindingVersion": { + "type": "string", + "description": "The version of this binding.", + "default": "latest" + } + }, + "required": [ + "consumers" + ], + "definitions": { + "identifier": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "url": { + "type": "string", + "description": "The endpoint is a URL." + }, + "email": { + "type": "string", + "description": "The endpoint is an email address." + }, + "phone": { + "type": "string", + "description": "The endpoint is a phone number." + }, + "arn": { + "type": "string", + "description": "The target is an ARN. For example, for SQS, the identifier may be an ARN, which will be of the form: arn:aws:sqs:{region}:{account-id}:{queueName}" + }, + "name": { + "type": "string", + "description": "The endpoint is identified by a name, which corresponds to an identifying field called 'name' of a binding for that protocol on this publish Operation Object. For example, if the protocol is 'sqs' then the name refers to the name field sqs binding. We don't use $ref because we are referring, not including." + } + } + }, + "consumer": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "protocol": { + "description": "The protocol that this endpoint receives messages by.", + "type": "string", + "enum": [ + "http", + "https", + "email", + "email-json", + "sms", + "sqs", + "application", + "lambda", + "firehose" + ] + }, + "endpoint": { + "description": "The endpoint messages are delivered to.", + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/identifier" + }, + "filterPolicy": { + "type": "object", + "description": "Only receive a subset of messages from the channel, determined by this policy. Depending on the FilterPolicyScope, a map of either a message attribute or message body to an array of possible matches. The match may be a simple string for an exact match, but it may also be an object that represents a constraint and values for that constraint.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "additionalProperties": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "object" + } + ] + } + }, + "filterPolicyScope": { + "type": "string", + "description": "Determines whether the FilterPolicy applies to MessageAttributes or MessageBody.", + "enum": [ + "MessageAttributes", + "MessageBody" + ], + "default": "MessageAttributes" + }, + "rawMessageDelivery": { + "type": "boolean", + "description": "If true AWS SNS attributes are removed from the body, and for SQS, SNS message attributes are copied to SQS message attributes. If false the SNS attributes are included in the body." + }, + "redrivePolicy": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/redrivePolicy" + }, + "deliveryPolicy": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/deliveryPolicy", + "description": "Policy for retries to HTTP. The parameter is for that SNS Subscription and overrides any policy on the SNS Topic." + }, + "displayName": { + "type": "string", + "description": "The display name to use with an SNS subscription" + } + }, + "required": [ + "protocol", + "endpoint", + "rawMessageDelivery" + ] + }, + "deliveryPolicy": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "minDelayTarget": { + "type": "integer", + "description": "The minimum delay for a retry in seconds." + }, + "maxDelayTarget": { + "type": "integer", + "description": "The maximum delay for a retry in seconds." + }, + "numRetries": { + "type": "integer", + "description": "The total number of retries, including immediate, pre-backoff, backoff, and post-backoff retries." + }, + "numNoDelayRetries": { + "type": "integer", + "description": "The number of immediate retries (with no delay)." + }, + "numMinDelayRetries": { + "type": "integer", + "description": "The number of immediate retries (with delay)." + }, + "numMaxDelayRetries": { + "type": "integer", + "description": "The number of post-backoff phase retries, with the maximum delay between retries." + }, + "backoffFunction": { + "type": "string", + "description": "The algorithm for backoff between retries.", + "enum": [ + "arithmetic", + "exponential", + "geometric", + "linear" + ] + }, + "maxReceivesPerSecond": { + "type": "integer", + "description": "The maximum number of deliveries per second, per subscription." + } + } + }, + "redrivePolicy": { + "type": "object", + "description": "Prevent poison pill messages by moving un-processable messages to an SQS dead letter queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "deadLetterQueue": { + "$ref": "http://asyncapi.com/bindings/sns/0.1.0/operation.json#/definitions/identifier", + "description": "The SQS queue to use as a dead letter queue (DLQ)." + }, + "maxReceiveCount": { + "type": "integer", + "description": "The number of times a message is delivered to the source queue before being moved to the dead-letter queue.", + "default": 10 + } + }, + "required": [ + "deadLetterQueue" + ] + } + }, + "examples": [ + { + "topic": { + "name": "someTopic" + }, + "consumers": [ + { + "protocol": "sqs", + "endpoint": { + "name": "someQueue" + }, + "filterPolicy": { + "store": [ + "asyncapi_corp" + ], + "event": [ + { + "anything-but": "order_cancelled" + } + ], + "customer_interests": [ + "rugby", + "football", + "baseball" + ] + }, + "filterPolicyScope": "MessageAttributes", + "rawMessageDelivery": false, + "redrivePolicy": { + "deadLetterQueue": { + "arn": "arn:aws:SQS:eu-west-1:0000000:123456789" + }, + "maxReceiveCount": 25 + }, + "deliveryPolicy": { + "minDelayTarget": 10, + "maxDelayTarget": 100, + "numRetries": 5, + "numNoDelayRetries": 2, + "numMinDelayRetries": 3, + "numMaxDelayRetries": 5, + "backoffFunction": "linear", + "maxReceivesPerSecond": 2 + } + } + ] + } + ] + }, + "http://asyncapi.com/bindings/sqs/0.2.0/operation.json": { + "$id": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json", + "title": "Operation Schema", + "description": "This object contains information about the operation representation in SQS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "queues": { + "type": "array", + "description": "Queue objects that are either the endpoint for an SNS Operation Binding Object, or the deadLetterQueue of the SQS Operation Binding Object.", + "items": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json#/definitions/queue" + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0", + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed.", + "default": "latest" + } + }, + "required": [ + "queues" + ], + "definitions": { + "queue": { + "type": "object", + "description": "A definition of a queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "$ref": { + "type": "string", + "description": "Allows for an external definition of a queue. The referenced structure MUST be in the format of a Queue. If there are conflicts between the referenced definition and this Queue's definition, the behavior is undefined." + }, + "name": { + "type": "string", + "description": "The name of the queue. When an SNS Operation Binding Object references an SQS queue by name, the identifier should be the one in this field." + }, + "fifoQueue": { + "type": "boolean", + "description": "Is this a FIFO queue?", + "default": false + }, + "deduplicationScope": { + "type": "string", + "enum": [ + "queue", + "messageGroup" + ], + "description": "Specifies whether message deduplication occurs at the message group or queue level. Valid values are messageGroup and queue (default).", + "default": "queue" + }, + "fifoThroughputLimit": { + "type": "string", + "enum": [ + "perQueue", + "perMessageGroupId" + ], + "description": "Specifies whether the FIFO queue throughput quota applies to the entire queue or per message group. Valid values are perQueue (default) and perMessageGroupId.", + "default": "perQueue" + }, + "deliveryDelay": { + "type": "integer", + "description": "The number of seconds to delay before a message sent to the queue can be received. Used to create a delay queue.", + "minimum": 0, + "maximum": 900, + "default": 0 + }, + "visibilityTimeout": { + "type": "integer", + "description": "The length of time, in seconds, that a consumer locks a message - hiding it from reads - before it is unlocked and can be read again.", + "minimum": 0, + "maximum": 43200, + "default": 30 + }, + "receiveMessageWaitTime": { + "type": "integer", + "description": "Determines if the queue uses short polling or long polling. Set to zero the queue reads available messages and returns immediately. Set to a non-zero integer, long polling waits the specified number of seconds for messages to arrive before returning.", + "default": 0 + }, + "messageRetentionPeriod": { + "type": "integer", + "description": "How long to retain a message on the queue in seconds, unless deleted.", + "minimum": 60, + "maximum": 1209600, + "default": 345600 + }, + "redrivePolicy": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json#/definitions/redrivePolicy" + }, + "policy": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json#/definitions/policy" + }, + "tags": { + "type": "object", + "description": "Key-value pairs that represent AWS tags on the queue." + } + }, + "required": [ + "name" + ] + }, + "redrivePolicy": { + "type": "object", + "description": "Prevent poison pill messages by moving un-processable messages to an SQS dead letter queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "deadLetterQueue": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json#/definitions/identifier" + }, + "maxReceiveCount": { + "type": "integer", + "description": "The number of times a message is delivered to the source queue before being moved to the dead-letter queue.", + "default": 10 + } + }, + "required": [ + "deadLetterQueue" + ] + }, + "identifier": { + "type": "object", + "description": "The SQS queue to use as a dead letter queue (DLQ).", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "arn": { + "type": "string", + "description": "The target is an ARN. For example, for SQS, the identifier may be an ARN, which will be of the form: arn:aws:sqs:{region}:{account-id}:{queueName}" + }, + "name": { + "type": "string", + "description": "The endpoint is identified by a name, which corresponds to an identifying field called 'name' of a binding for that protocol on this publish Operation Object. For example, if the protocol is 'sqs' then the name refers to the name field sqs binding." + } + } + }, + "policy": { + "type": "object", + "description": "The security policy for the SQS Queue", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "statements": { + "type": "array", + "description": "An array of statement objects, each of which controls a permission for this queue.", + "items": { + "$ref": "http://asyncapi.com/bindings/sqs/0.2.0/operation.json#/definitions/statement" + } + } + }, + "required": [ + "statements" + ] + }, + "statement": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "principal": { + "description": "The AWS account or resource ARN that this statement applies to.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "action": { + "description": "The SQS permission being allowed or denied e.g. sqs:ReceiveMessage", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "effect", + "principal", + "action" + ] + } + }, + "examples": [ + { + "queues": [ + { + "name": "myQueue", + "fifoQueue": true, + "deduplicationScope": "messageGroup", + "fifoThroughputLimit": "perMessageGroupId", + "deliveryDelay": 10, + "redrivePolicy": { + "deadLetterQueue": { + "name": "myQueue_error" + }, + "maxReceiveCount": 15 + }, + "policy": { + "statements": [ + { + "effect": "Deny", + "principal": "arn:aws:iam::123456789012:user/dec.kolakowski", + "action": [ + "sqs:SendMessage", + "sqs:ReceiveMessage" + ] + } + ] + } + }, + { + "name": "myQueue_error", + "deliveryDelay": 10 + } + ] + } + ] + }, + "http://asyncapi.com/bindings/solace/0.4.0/operation.json": { + "$id": "http://asyncapi.com/bindings/solace/0.4.0/operation.json", + "title": "Solace operation bindings object", + "description": "This object contains information about the operation representation in Solace.", + "type": "object", + "additionalProperties": false, + "properties": { + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + }, + "destinations": { + "description": "The list of Solace destinations referenced in the operation.", + "type": "array", + "items": { + "type": "object", + "properties": { + "deliveryMode": { + "type": "string", + "enum": [ + "direct", + "persistent" + ] + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "type": "string", + "const": "queue", + "description": "If the type is queue, then the subscriber can bind to the queue. The queue subscribes to the given topicSubscriptions. If no topicSubscriptions are provied, the queue will subscribe to the topic as represented by the channel name." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the queue" + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the queue subscribes to.", + "items": { + "type": "string" + } + }, + "accessType": { + "type": "string", + "enum": [ + "exclusive", + "nonexclusive" + ] + }, + "maxTtl": { + "type": "string", + "description": "The maximum TTL to apply to messages to be spooled." + }, + "maxMsgSpoolUsage": { + "type": "string", + "description": "The maximum amount of message spool that the given queue may use" + } + } + } + } + }, + { + "properties": { + "destinationType": { + "type": "string", + "const": "topic", + "description": "If the type is topic, then the subscriber subscribes to the given topicSubscriptions. If no topicSubscriptions are provided, the client will subscribe to the topic as represented by the channel name." + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the client subscribes to.", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "timeToLive": { + "type": "integer", + "description": "Interval in milliseconds or a Schema Object containing the definition of the lifetime of the message." + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "The valid priority value range is 0-255 with 0 as the lowest priority and 255 as the highest or a Schema Object containing the definition of the priority." + }, + "dmqEligible": { + "type": "boolean", + "description": "Set the message to be eligible to be moved to a Dead Message Queue. The default value is false." + } + }, + "examples": [ + { + "bindingVersion": "0.4.0", + "destinations": [ + { + "destinationType": "queue", + "queue": { + "name": "sampleQueue", + "topicSubscriptions": [ + "samples/*" + ], + "accessType": "nonexclusive" + } + }, + { + "destinationType": "topic", + "topicSubscriptions": [ + "samples/*" + ] + } + ] + } + ] + }, + "http://asyncapi.com/bindings/solace/0.3.0/operation.json": { + "$id": "http://asyncapi.com/bindings/solace/0.3.0/operation.json", + "title": "Solace operation bindings object", + "description": "This object contains information about the operation representation in Solace.", + "type": "object", + "additionalProperties": false, + "properties": { + "destinations": { + "description": "The list of Solace destinations referenced in the operation.", + "type": "array", + "items": { + "type": "object", + "properties": { + "deliveryMode": { + "type": "string", + "enum": [ + "direct", + "persistent" + ] + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "type": "string", + "const": "queue", + "description": "If the type is queue, then the subscriber can bind to the queue. The queue subscribes to the given topicSubscriptions. If no topicSubscriptions are provied, the queue will subscribe to the topic as represented by the channel name." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the queue" + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the queue subscribes to.", + "items": { + "type": "string" + } + }, + "accessType": { + "type": "string", + "enum": [ + "exclusive", + "nonexclusive" + ] + }, + "maxTtl": { + "type": "string", + "description": "The maximum TTL to apply to messages to be spooled." + }, + "maxMsgSpoolUsage": { + "type": "string", + "description": "The maximum amount of message spool that the given queue may use" + } + } + } + } + }, + { + "properties": { + "destinationType": { + "type": "string", + "const": "topic", + "description": "If the type is topic, then the subscriber subscribes to the given topicSubscriptions. If no topicSubscriptions are provided, the client will subscribe to the topic as represented by the channel name." + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the client subscribes to.", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "bindingVersion": "0.3.0", + "destinations": [ + { + "destinationType": "queue", + "queue": { + "name": "sampleQueue", + "topicSubscriptions": [ + "samples/*" + ], + "accessType": "nonexclusive" + } + }, + { + "destinationType": "topic", + "topicSubscriptions": [ + "samples/*" + ] + } + ] + } + ] + }, + "http://asyncapi.com/bindings/solace/0.2.0/operation.json": { + "$id": "http://asyncapi.com/bindings/solace/0.2.0/operation.json", + "title": "Solace operation bindings object", + "description": "This object contains information about the operation representation in Solace.", + "type": "object", + "additionalProperties": false, + "properties": { + "destinations": { + "description": "The list of Solace destinations referenced in the operation.", + "type": "array", + "items": { + "type": "object", + "properties": { + "deliveryMode": { + "type": "string", + "enum": [ + "direct", + "persistent" + ] + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "type": "string", + "const": "queue", + "description": "If the type is queue, then the subscriber can bind to the queue. The queue subscribes to the given topicSubscriptions. If no topicSubscriptions are provied, the queue will subscribe to the topic as represented by the channel name." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the queue" + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the queue subscribes to.", + "items": { + "type": "string" + } + }, + "accessType": { + "type": "string", + "enum": [ + "exclusive", + "nonexclusive" + ] + } + } + } + } + }, + { + "properties": { + "destinationType": { + "type": "string", + "const": "topic", + "description": "If the type is topic, then the subscriber subscribes to the given topicSubscriptions. If no topicSubscriptions are provided, the client will subscribe to the topic as represented by the channel name." + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the client subscribes to.", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "bindingVersion": "0.2.0", + "destinations": [ + { + "destinationType": "queue", + "queue": { + "name": "sampleQueue", + "topicSubscriptions": [ + "samples/*" + ], + "accessType": "nonexclusive" + } + }, + { + "destinationType": "topic", + "topicSubscriptions": [ + "samples/*" + ] + } + ] + } + ] + }, + "http://asyncapi.com/definitions/3.0.0/components.json": { + "$id": "http://asyncapi.com/definitions/3.0.0/components.json", + "type": "object", + "description": "An object to hold a set of reusable objects for different aspects of the AsyncAPI specification. All objects defined within the components object will have no effect on the API unless they are explicitly referenced from properties outside the components object.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/specificationExtension.json" + } + }, + "properties": { + "schemas": { + "type": "object", + "description": "An object to hold reusable Schema Object. If this is a Schema Object, then the schemaFormat will be assumed to be 'application/vnd.aai.asyncapi+json;version=asyncapi' where the version is equal to the AsyncAPI Version String.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "$ref": "http://asyncapi.com/definitions/3.0.0/anySchema.json" + } + } + }, + "servers": { + "type": "object", + "description": "An object to hold reusable Server Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/server.json" + } + ] + } + } + }, + "channels": { + "type": "object", + "description": "An object to hold reusable Channel Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/channel.json" + } + ] + } + } + }, + "serverVariables": { + "type": "object", + "description": "An object to hold reusable Server Variable Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/serverVariable.json" + } + ] + } + } + }, + "operations": { + "type": "object", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operation.json" + } + ] + } + } + }, + "messages": { + "type": "object", + "description": "An object to hold reusable Message Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageObject.json" + } + ] + } + } + }, + "securitySchemes": { + "type": "object", + "description": "An object to hold reusable Security Scheme Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/SecurityScheme.json" + } + ] + } + } + }, + "parameters": { + "type": "object", + "description": "An object to hold reusable Parameter Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/parameter.json" + } + ] + } + } + }, + "correlationIds": { + "type": "object", + "description": "An object to hold reusable Correlation ID Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/correlationId.json" + } + ] + } + } + }, + "operationTraits": { + "type": "object", + "description": "An object to hold reusable Operation Trait Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationTrait.json" + } + ] + } + } + }, + "messageTraits": { + "type": "object", + "description": "An object to hold reusable Message Trait Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageTrait.json" + } + ] + } + } + }, + "replies": { + "type": "object", + "description": "An object to hold reusable Operation Reply Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationReply.json" + } + ] + } + } + }, + "replyAddresses": { + "type": "object", + "description": "An object to hold reusable Operation Reply Address Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationReplyAddress.json" + } + ] + } + } + }, + "serverBindings": { + "type": "object", + "description": "An object to hold reusable Server Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/serverBindingsObject.json" + } + ] + } + } + }, + "channelBindings": { + "type": "object", + "description": "An object to hold reusable Channel Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/channelBindingsObject.json" + } + ] + } + } + }, + "operationBindings": { + "type": "object", + "description": "An object to hold reusable Operation Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/operationBindingsObject.json" + } + ] + } + } + }, + "messageBindings": { + "type": "object", + "description": "An object to hold reusable Message Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/messageBindingsObject.json" + } + ] + } + } + }, + "tags": { + "type": "object", + "description": "An object to hold reusable Tag Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/tag.json" + } + ] + } + } + }, + "externalDocs": { + "type": "object", + "description": "An object to hold reusable External Documentation Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "http://asyncapi.com/definitions/3.0.0/Reference.json" + }, + { + "$ref": "http://asyncapi.com/definitions/3.0.0/externalDocs.json" + } + ] + } + } + } + }, + "examples": [ + { + "components": { + "schemas": { + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "AvroExample": { + "schemaFormat": "application/vnd.apache.avro+json;version=1.9.0", + "schema": { + "$ref": "path/to/user-create.avsc#/UserCreate" + } + } + }, + "servers": { + "development": { + "host": "{stage}.in.mycompany.com:{port}", + "description": "RabbitMQ broker", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "variables": { + "stage": { + "$ref": "#/components/serverVariables/stage" + }, + "port": { + "$ref": "#/components/serverVariables/port" + } + } + } + }, + "serverVariables": { + "stage": { + "default": "demo", + "description": "This value is assigned by the service provider, in this example `mycompany.com`" + }, + "port": { + "enum": [ + "5671", + "5672" + ], + "default": "5672" + } + }, + "channels": { + "user/signedup": { + "subscribe": { + "message": { + "$ref": "#/components/messages/userSignUp" + } + } + } + }, + "messages": { + "userSignUp": { + "summary": "Action to sign a user up.", + "description": "Multiline description of what this action does.\nHere you have another line.\n", + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + } + ], + "headers": { + "type": "object", + "properties": { + "applicationInstanceId": { + "description": "Unique identifier for a given instance of the publishing application", + "type": "string" + } + } + }, + "payload": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/userCreate" + }, + "signup": { + "$ref": "#/components/schemas/signup" + } + } + } + } + }, + "parameters": { + "userId": { + "description": "Id of the user." + } + }, + "correlationIds": { + "default": { + "description": "Default Correlation ID", + "location": "$message.header#/correlationId" + } + }, + "messageTraits": { + "commonHeaders": { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + } + } + } + } + ] + } + }, + "description": "!!Auto generated!! \n Do not manually edit. " +} \ No newline at end of file diff --git a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml index 8f0ae873e..d772f000b 100644 --- a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml @@ -167,6 +167,33 @@ properties: - mimetype - encoding - map + pubsub: + type: object + description: Pub/Sub settings for event driven notifications + properties: + name: + type: string + description: name of pubsub client + broker: + type: object + description: broker definition + properties: + url: + type: string + format: uri + description: URL of broker + channel: + type: string + description: channel to subscribe to + hidden: + type: boolean + default: false + description: whether to hide broker link on API responses + required: + - url + required: + - name + - broker logging: type: object description: logging definitions diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py index dfe059f8a..ffb5b656f 100644 --- a/pygeoapi/starlette_app.py +++ b/pygeoapi/starlette_app.py @@ -5,7 +5,7 @@ # Abdulazeez Abdulazeez Adeshina # # Copyright (c) 2025 Francesco Bartoli -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 Abdulazeez Abdulazeez Adeshina # # Permission is hereby granted, free of charge, to any person @@ -59,6 +59,7 @@ import pygeoapi.api.processes as processes_api import pygeoapi.api.stac as stac_api import pygeoapi.api.tiles as tiles_api +from pygeoapi.asyncapi import load_asyncapi_document from pygeoapi.openapi import load_openapi_document from pygeoapi.config import get_config from pygeoapi.util import get_api_rules @@ -69,6 +70,7 @@ raise RuntimeError('PYGEOAPI_OPENAPI environment variable not set') OPENAPI = load_openapi_document() +ASYNCAPI = load_asyncapi_document() if CONFIG['server'].get('admin'): import pygeoapi.api.admin as admin_api @@ -86,7 +88,7 @@ API_RULES = get_api_rules(CONFIG) -api_ = API(CONFIG, OPENAPI) +api_ = API(CONFIG, OPENAPI, ASYNCAPI) def call_api_threadsafe( @@ -167,6 +169,17 @@ async def openapi(request: Request) -> Response: return await execute_from_starlette(core_api.openapi_, request) +async def asyncapi(request: Request) -> Response: + """ + AsyncAPI endpoint + + :param request: Starlette Request instance + + :returns: Starlette HTTP Response + """ + return await execute_from_starlette(core_api.asyncapi_, request) + + async def conformance(request: Request) -> Response: """ OGC API conformance endpoint @@ -699,6 +712,7 @@ async def __call__(self, scope: Scope, api_routes = [ Route('/', landing_page), Route('/openapi', openapi), + Route('/asyncapi', asyncapi), Route('/conformance', conformance), Route('/TileMatrixSets/{tileMatrixSetId}', get_tilematrix_set), Route('/TileMatrixSets', get_tilematrix_sets), diff --git a/pygeoapi/templates/asyncapi.html b/pygeoapi/templates/asyncapi.html new file mode 100644 index 000000000..bc1915150 --- /dev/null +++ b/pygeoapi/templates/asyncapi.html @@ -0,0 +1,24 @@ + + + + AsyncAPI UI - {{ config['metadata']['identification']['title'] }} + + + +
+ + + + diff --git a/pygeoapi/templates/landing_page.html b/pygeoapi/templates/landing_page.html index 8995e88a5..69e601787 100644 --- a/pygeoapi/templates/landing_page.html +++ b/pygeoapi/templates/landing_page.html @@ -81,7 +81,22 @@

{% trans %}Jobs{% endtrans %}

{% trans %}Browse jobs{% endtrans %}

- {% endif %} + {% endif %} + {% if data['tile'] %} +
+

{% trans %}Tile Matrix Sets{% endtrans %}

+

+ {% trans %}View the Tile Matrix Sets available on this service{% endtrans %} +

+ {% endif %} + {% if data['pubsub'] %} +
+

{% trans %}Pub/Sub Notifications{% endtrans %} ({{ data['pubsub']['name'] }})

+

+ {% trans %}Subscribe to notifications from this service{% endtrans %}{% if data['pubsub']['channel'] %} (Topic/channel: {{ data['pubsub']['channel'] }}) {% endif %} +

+
+ {% endif %}

{% trans %}API Definition{% endtrans %}

@@ -91,20 +106,23 @@

{% trans %}API Definition{% endtrans %}

{% trans %}OpenAPI Document{% endtrans %}

-
-

{% trans %}Conformance{% endtrans %}

+ {% if data['pubsub'] and data['pubsub']['asyncapi'] %} +
+

{% trans %}AsyncAPI Definition{% endtrans %}

- {% trans %}View the conformance classes of this service{% endtrans %} + {% trans %}Documentation{% endtrans %}: {% trans %}UI{% endtrans %}

-
- {% if data['tile'] %} -
-

{% trans %}Tile Matrix Sets{% endtrans %}

- {% trans %}View the Tile Matrix Sets available on this service{% endtrans %} + {% trans %}AsyncAPI Document{% endtrans %}

{% endif %} +
+

{% trans %}Conformance{% endtrans %}

+

+ {% trans %}View the conformance classes of this service{% endtrans %} +

+
diff --git a/pygeoapi/util.py b/pygeoapi/util.py index a4b3ad0c5..44406e5b5 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -249,7 +249,7 @@ def str2bool(value: Union[bool, str]) -> bool: def to_json(dict_: dict, pretty: bool = False) -> str: """ - Serialize dict to json + Serialize dict to JSON :param dict_: `dict` of JSON representation :param pretty: `bool` of whether to prettify JSON (default is `False`) @@ -792,3 +792,15 @@ def get_dataset_formatters(dataset: dict) -> dict: dataset_formatters[df2.name] = df2 return dataset_formatters + + +def remove_url_auth(url: str) -> str: + """ + Provide a RFC1738 URL without embedded authentication + :param url: RFC1738 URL + :returns: RFC1738 URL without authentication + """ + + u = urlparse(url) + auth = f'{u.username}:{u.password}@' + return url.replace(auth, '') diff --git a/requirements-pubsub.txt b/requirements-pubsub.txt new file mode 100644 index 000000000..8579e8b22 --- /dev/null +++ b/requirements-pubsub.txt @@ -0,0 +1 @@ +paho-mqtt diff --git a/tests/api/test_pubsub.py b/tests/api/test_pubsub.py new file mode 100644 index 000000000..243c4661d --- /dev/null +++ b/tests/api/test_pubsub.py @@ -0,0 +1,106 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from copy import deepcopy +import json + +import pytest + +from pygeoapi.api import API, landing_page +from pygeoapi.util import yaml_load + +from tests.util import get_test_file_path, mock_api_request + + +@pytest.fixture() +def config(): + with open(get_test_file_path('pygeoapi-test-config-pubsub.yml')) as fh: + return yaml_load(fh) + + +def test_landing_page(config, openapi, asyncapi): + api_ = API(config, openapi, asyncapi) + + broker_link = None + + req = mock_api_request() + rsp_headers, code, response = landing_page(api_, req) + + content = json.loads(response) + + assert len(content['links']) == 15 + + for link in content['links']: + if link.get('rel') == 'hub': + broker_link = link + + assert broker_link is not None + assert broker_link['href'] == 'mqtt://localhost:1883' + assert broker_link['channel'] == 'my/channel' + + config2 = deepcopy(config) + config2['pubsub']['broker']['hidden'] = True + + api_ = API(config2, openapi) + + broker_link = None + + req = mock_api_request() + rsp_headers, code, response = landing_page(api_, req) + + content = json.loads(response) + + assert len(content['links']) == 12 + + for link in content['links']: + if link.get('rel') == 'hub': + broker_link = link + + assert broker_link is None + + config2 = deepcopy(config) + config2['pubsub']['broker'].pop('channel', None) + + api_ = API(config2, openapi, asyncapi) + + broker_link = None + + req = mock_api_request() + rsp_headers, code, response = landing_page(api_, req) + + content = json.loads(response) + + assert len(content['links']) == 15 + + for link in content['links']: + if link.get('rel') == 'hub': + broker_link = link + + assert broker_link is not None + assert 'channel' not in broker_link diff --git a/tests/conftest.py b/tests/conftest.py index d17a7107d..b9eef7b63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,12 @@ def openapi(): return yaml_load(fh) +@pytest.fixture() +def asyncapi(): + with open(get_test_file_path('pygeoapi-test-asyncapi.yml')) as fh: + return yaml_load(fh) + + @pytest.fixture() def api_(config, openapi): return API(config, openapi) diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index c8cb8140e..da3d0f99e 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -892,6 +892,13 @@ def test_transaction_basic_workflow(pg_api_, data): identifier=123) assert code == HTTPStatus.OK + # delete again (item should not be in backend) + req = mock_api_request(data=data) + headers, code, content = manage_collection_item( + pg_api_, req, action='delete', dataset='hot_osm_waterways', + identifier=123) + assert code == HTTPStatus.NOT_FOUND + def test_transaction_create_handles_invalid_input_data(pg_api_, data): data_parsed = json.loads(data) diff --git a/tests/pygeoapi-test-asyncapi.yml b/tests/pygeoapi-test-asyncapi.yml new file mode 100644 index 000000000..1e7d1292a --- /dev/null +++ b/tests/pygeoapi-test-asyncapi.yml @@ -0,0 +1,56 @@ +asyncapi: 3.0.0 +channels: + notify-canada-metadata: + address: collections/canada-metadata + description: Open Canada sample data + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml + notify-heif-sample: + address: collections/heif-sample + description: HEIF sample + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml +defaultContentType: application/json +id: http://localhost:5000 +info: + contact: + email: you@example.org + name: Lastname, Firstname + description: pygeoapi provides an API to geospatial data https://github.com + externalDocs: + url: https://github.com/geopython/demo.pygeoapi.io/tree/master/services/pygeoapi_cite + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + tags: + - name: geospatial + - name: data + - name: api + title: pygeoapi default instance + version: 0.23.dev0 +operations: + consume-canada-metadata: + action: receive + channel: + $ref: '#/channels/notify-canada-metadata' + consume-heif-sample: + action: receive + channel: + $ref: '#/channels/notify-heif-sample' + publish-canada-metadata: + action: send + channel: + $ref: '#/channels/notify-canada-metadata' + publish-heif-sample: + action: send + channel: + $ref: '#/channels/notify-heif-sample' +servers: + default: + description: pygeoapi provides an API to geospatial data https://github.com + host: localhost:1883 + protocol: mqtt diff --git a/tests/pygeoapi-test-config-pubsub.yml b/tests/pygeoapi-test-config-pubsub.yml new file mode 100644 index 000000000..51ed71d16 --- /dev/null +++ b/tests/pygeoapi-test-config-pubsub.yml @@ -0,0 +1,458 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + +pubsub: + name: MQTT + broker: + url: mqtt://everyone:everyone@localhost:1883 + channel: my/channel + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + obs: + type: collection + title: + en: Observations + fr: Observations + description: + en: My cool observations + fr: Mes belles observations + keywords: + - observations + - monitoring + links: + - type: text/csv + rel: canonical + title: data + href: https://github.com/mapserver/mapserver/blob/branch-7-0/msautotest/wxs/data/obs.csv + hreflang: en-US + - type: text/csv + rel: alternate + title: data + href: https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv + hreflang: en-US + 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] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: 2000-10-30T18:24:39Z + end: 2007-10-30T08:57:29Z + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + providers: + - type: feature + name: CSV + data: tests/data/obs.csv + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/3857 + - http://www.opengis.net/def/crs/EPSG/0/28992 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + id_field: id + geometry: + x_field: long + y_field: lat + + norway_pop: + type: collection + title: Norwegian urban areas + description: Most populated Norwegian urban areas + keywords: + - population + - Norway + links: + - type: text/html + rel: canonical + title: information + href: https://www.ssb.no/statbank/table/04859/ + hreflang: nb-NO + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: CSV + data: tests/data/norwegian_urban_areas.csv + id_field: id + geometry: + x_field: easting + y_field: northing + crs: + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/25833 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/25833 + + cmip5: + type: collection + title: CMIP5 sample + description: CMIP5 sample + keywords: + - cmip5 + - climate + extents: + spatial: + bbox: [-150,40,-45,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://open.canada.ca/data/en/dataset/eddd6eaf-34d7-4452-a994-3d928115a68b + hreflang: en-CA + providers: + - type: coverage + name: xarray + data: tests/data/CMIP5_rcp8.5_annual_abs_latlon1x1_PCP_pctl25_P1Y.nc + x_field: lon + y_field: lat + time_field: time + format: + name: NetCDF + mimetype: application/x-netcdf + + naturalearth/lakes: + type: collection + title: + en: Large Lakes + fr: Grands Lacs + description: + en: lakes of the world, public domain + fr: lacs du monde, domaine public + keywords: + - lakes + links: + - type: text/html + rel: canonical + title: information + href: http://www.naturalearthdata.com/ + hreflang: en-US + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: 2011-11-11T11:11:11Z + end: null # or empty (either means open ended) + providers: + - type: feature + name: GeoJSON + data: tests/data/ne_110m_lakes.geojson + id_field: id + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + storage_crs_coordinate_epoch: 2017.23 + - type: tile + name: MVT-tippecanoe + # data: http://localhost:9000/ne_110m_lakes/{z}/{x}/{y} + data: tests/data/tiles/ne_110m_lakes + options: + bounds: [[-124.953634,-16.536406],[109.929807,66.969298]] + zoom: + min: 0 + max: 11 + schemes: + - WorldCRS84Quad + format: + name: pbf + mimetype: application/vnd.mapbox-vector-tile + + gdps-temperature: + type: collection + title: Global Deterministic Prediction System sample + description: Global Deterministic Prediction System sample + keywords: + - gdps + - global + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://eccc-msc.github.io/open-data/msc-data/nwp_gdps/readme_gdps_en + hreflang: en-CA + providers: + - type: coverage + name: rasterio + data: tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 + format: + name: GRIB + mimetype: application/x-grib2 + + icoads-sst: + type: collection + title: International Comprehensive Ocean-Atmosphere Data Set (ICOADS) + description: International Comprehensive Ocean-Atmosphere Data Set (ICOADS) + keywords: + - icoads + - sst + - air temperature + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://psl.noaa.gov/data/gridded/data.coads.1deg.html + hreflang: en-US + providers: + - type: edr + name: xarray-edr + data: tests/data/coads_sst.nc + format: + name: NetCDF + mimetype: application/x-netcdf + + usgs-prism: + type: collection + title: Parameter-elevation Regressions on Independent Slopes Model (PRISM) + description: PRISM Monthly Climate Data for the Continental United States + keywords: + - temperature + - precipitation + extents: + spatial: + bbox: [-125.020836, 24.104166, -66.520836, 49.9375] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://cida.usgs.gov/thredds/catalog.html?dataset=cida.usgs.gov/prism_v2 + hreflang: en-US + providers: + - type: edr + name: xarray-edr + data: s3://mdmf/gdp/PRISM_v2.zarr + format: + name: zarr + mimetype: application/zip + options: + s3: + anon: true + requester_pays: false + client_kwargs: + endpoint_url: https://usgs.osn.mghpcc.org/ + + objects: + type: collection + title: GeoJSON objects + description: GeoJSON geometry types for GeoSparql and Schema Geometry conversion. + keywords: + - shapes + links: + - type: text/html + rel: canonical + title: data source + href: https://en.wikipedia.org/wiki/GeoJSON + hreflang: en-US + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: null + end: null # or empty (either means open ended) + providers: + - type: feature + name: GeoJSON + data: tests/data/items.geojson + id_field: fid + uri_field: uri + + mapserver_world_map: + type: collection + title: MapServer demo WMS world map + description: MapServer demo WMS world map + keywords: + - MapServer + - world map + links: + - type: text/html + rel: canonical + title: information + href: https://demo.mapserver.org + hreflang: en-US + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + custom-extent: + url: https://example.org/custom-extent + units: °C + range: [0, 10] + values: [0, 5, 10] + providers: + - type: map + name: WMSFacade + data: https://demo.mapserver.org/cgi-bin/msautotest + options: + layer: world_latlong + style: default + format: + name: png + mimetype: image/png + + canada-metadata: + type: collection + title: + en: Open Canada sample data + fr: Exemple de donn\u00e9es Canada Ouvert + description: + en: Sample metadata records from open.canada.ca + fr: Exemples d'enregistrements de m\u00e9tadonn\u00e9es sur ouvert.canada.ca + keywords: + en: + - canada + - open data + fr: + - canada + - donn\u00e9es ouvertes + links: + - type: text/html + rel: canonical + title: information + href: https://open.canada.ca/en/open-data + hreflang: en-CA + - type: text/html + rel: alternate + title: informations + href: https://ouvert.canada.ca/fr/donnees-ouvertes + hreflang: fr-CA + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: record + name: TinyDBCatalogue + data: tests/data/open.canada.ca/sample-records.tinydb + id_field: externalId + time_field: created + title_field: title + + hello-world: + type: process + processor: + name: HelloWorld + + pygeometa-metadata-validate: + type: process + processor: + name: pygeometa.pygeoapi_plugin.PygeometaMetadataValidateProcessor From 478864c49ffe6f80ac00910d42de6ba7a6b6c81f Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 29 Jan 2026 12:07:21 -0500 Subject: [PATCH 11/41] fix AsyncAPI handling on Docker startup (#2227) --- docker/entrypoint.sh | 3 +-- pygeoapi/asyncapi.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ae70a87eb..0b0f47301 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -93,8 +93,7 @@ echo "openapi.yml generated continue to pygeoapi" echo "Trying to generate asyncapi.yml" /venv/bin/pygeoapi asyncapi generate ${PYGEOAPI_CONFIG} --output-file ${PYGEOAPI_ASYNCAPI} -[[ $? -ne 0 ]] && error "asyncapi.yml could not be generated ERROR" -echo "asyncapi.yml generated continue to pygeoapi" +[[ $? -ne 0 ]] && echo "asyncapi.yml could not be generated; skipping" start_gunicorn() { # SCRIPT_NAME should not have value '/' diff --git a/pygeoapi/asyncapi.py b/pygeoapi/asyncapi.py index 363ffa610..055cc1ba0 100644 --- a/pygeoapi/asyncapi.py +++ b/pygeoapi/asyncapi.py @@ -222,8 +222,8 @@ def load_asyncapi_document() -> dict: if not os.path.exists(pygeoapi_asyncapi): msg = (f'AsyncAPI document {pygeoapi_asyncapi} does not exist. ' 'Please generate before starting pygeoapi') - LOGGER.error(msg) - raise RuntimeError(msg) + LOGGER.warning(msg) + return {} with open(pygeoapi_asyncapi, encoding='utf8') as ff: if pygeoapi_asyncapi.endswith(('.yaml', '.yml')): From e5d79cb72370e88d4895d42e4a19838324e5ee74 Mon Sep 17 00:00:00 2001 From: Kieran Bartels <62659408+kieranbartels@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:31:46 -0500 Subject: [PATCH 12/41] Filtering Geospatial Data (#2165) - filters out any non-geospatial data fields (i.e. crs, lat_bounds) - fixes the UI position query issue caused by attempting to query timeseries data on the crs field - still need to test compatibility with the builder --- pygeoapi/provider/xarray_.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 36353243a..a19d62a6e 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -117,6 +117,8 @@ def get_fields(self): if not self._fields: for key, value in self._data.variables.items(): if key not in self._data.coords: + if not {self.time_field, self.x_field, self.y_field}.issubset(value.dims): + continue LOGGER.debug('Adding variable') dtype = value.dtype if dtype.name.startswith('float'): From e01111e2454b8907149d6f58baa02268ec489d3f Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sat, 31 Jan 2026 14:33:59 -0500 Subject: [PATCH 13/41] fix missing flake8 --- pygeoapi/provider/xarray_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index a19d62a6e..8b451665e 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -117,7 +117,7 @@ def get_fields(self): if not self._fields: for key, value in self._data.variables.items(): if key not in self._data.coords: - if not {self.time_field, self.x_field, self.y_field}.issubset(value.dims): + if not {self.time_field, self.x_field, self.y_field}.issubset(value.dims): # noqa continue LOGGER.debug('Adding variable') dtype = value.dtype From 3bd179bd04014ebcef5a0e87b006118770f82849 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sat, 31 Jan 2026 14:53:31 -0500 Subject: [PATCH 14/41] Revert "fix missing flake8" This reverts commit e01111e2454b8907149d6f58baa02268ec489d3f. --- pygeoapi/provider/xarray_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 8b451665e..a19d62a6e 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -117,7 +117,7 @@ def get_fields(self): if not self._fields: for key, value in self._data.variables.items(): if key not in self._data.coords: - if not {self.time_field, self.x_field, self.y_field}.issubset(value.dims): # noqa + if not {self.time_field, self.x_field, self.y_field}.issubset(value.dims): continue LOGGER.debug('Adding variable') dtype = value.dtype From ecd81c107a3df174bfa494a4546fe64ed27d17fe Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sat, 31 Jan 2026 14:53:41 -0500 Subject: [PATCH 15/41] Revert "Filtering Geospatial Data (#2165)" This reverts commit e5d79cb72370e88d4895d42e4a19838324e5ee74. --- pygeoapi/provider/xarray_.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index a19d62a6e..36353243a 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -117,8 +117,6 @@ def get_fields(self): if not self._fields: for key, value in self._data.variables.items(): if key not in self._data.coords: - if not {self.time_field, self.x_field, self.y_field}.issubset(value.dims): - continue LOGGER.debug('Adding variable') dtype = value.dtype if dtype.name.startswith('float'): From e2c5b866702867e56e875a369e0516b0a4c1ec6a Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 1 Feb 2026 11:57:59 -0500 Subject: [PATCH 16/41] move provider utils to pygeoapi.provider (#2228) --- pygeoapi/api/__init__.py | 5 +- pygeoapi/api/coverages.py | 5 +- pygeoapi/api/environmental_data_retrieval.py | 8 +-- pygeoapi/api/itemtypes.py | 7 +-- pygeoapi/api/maps.py | 6 +- pygeoapi/api/stac.py | 7 +-- pygeoapi/api/tiles.py | 6 +- pygeoapi/provider/__init__.py | 63 +++++++++++++++++++- pygeoapi/provider/sql.py | 2 + pygeoapi/util.py | 58 +----------------- tests/other/test_util.py | 13 ++-- 11 files changed, 90 insertions(+), 90 deletions(-) diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index ffc0a8810..c4bf94fd4 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -61,13 +61,14 @@ from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin from pygeoapi.process.manager.base import get_manager +from pygeoapi.provider import ( + filter_providers_by_type, get_provider_by_type, get_provider_default) from pygeoapi.provider.base import ( ProviderConnectionError, ProviderGenericError, ProviderTypeError) from pygeoapi.util import ( TEMPLATESDIR, UrlPrefetcher, dategetter, - filter_dict_by_key_value, filter_providers_by_type, get_api_rules, - get_base_url, get_provider_by_type, get_provider_default, get_typed_value, + filter_dict_by_key_value, get_api_rules, get_base_url, get_typed_value, render_j2_template, to_json, get_choice_from_headers, get_from_headers, get_dataset_formatters ) diff --git a/pygeoapi/api/coverages.py b/pygeoapi/api/coverages.py index b44a15fce..327744a01 100644 --- a/pygeoapi/api/coverages.py +++ b/pygeoapi/api/coverages.py @@ -46,9 +46,8 @@ from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError -from pygeoapi.util import ( - filter_dict_by_key_value, get_provider_by_type, to_json -) +from pygeoapi.provider import get_provider_by_type +from pygeoapi.util import filter_dict_by_key_value, to_json from . import ( APIRequest, API, F_JSON, SYSTEM_LOCALE, validate_bbox, validate_datetime, diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 853559156..7e1ef1f51 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -53,12 +53,12 @@ from pygeoapi.crs import (create_crs_transform_spec, set_content_crs_header) from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin, PLUGINS +from pygeoapi.provider import filter_providers_by_type, get_provider_by_type from pygeoapi.provider.base import ( ProviderGenericError, ProviderItemNotFoundError) -from pygeoapi.util import ( - filter_providers_by_type, get_dataset_formatters, get_provider_by_type, - get_typed_value, render_j2_template, to_json, filter_dict_by_key_value -) +from pygeoapi.util import (get_dataset_formatters, get_typed_value, + render_j2_template, to_json, + filter_dict_by_key_value) from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD, validate_datetime, validate_bbox) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 19306f57e..2aa85a973 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -58,14 +58,13 @@ from pygeoapi.linked_data import geojson2jsonld from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin, PLUGINS +from pygeoapi.provider import filter_providers_by_type, get_provider_by_type from pygeoapi.provider.base import ( ProviderGenericError, ProviderItemNotFoundError, ProviderTypeError, SchemaType) -from pygeoapi.util import (filter_providers_by_type, to_json, - filter_dict_by_key_value, str2bool, - get_provider_by_type, render_j2_template, - get_dataset_formatters) +from pygeoapi.util import (to_json, filter_dict_by_key_value, str2bool, + render_j2_template, get_dataset_formatters) from . import ( APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 888073938..bcdda4b7a 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -46,13 +46,11 @@ from pygeoapi.crs import transform_bbox from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin +from pygeoapi.provider import filter_providers_by_type, get_provider_by_type from pygeoapi.provider.base import ( ProviderGenericError, ProviderInvalidDataError ) -from pygeoapi.util import ( - get_provider_by_type, to_json, filter_providers_by_type, - filter_dict_by_key_value -) +from pygeoapi.util import to_json, filter_dict_by_key_value from . import ( APIRequest, API, F_JSON, FORMAT_TYPES, validate_datetime, validate_subset diff --git a/pygeoapi/api/stac.py b/pygeoapi/api/stac.py index 37dfbedc9..a9227da26 100644 --- a/pygeoapi/api/stac.py +++ b/pygeoapi/api/stac.py @@ -51,13 +51,12 @@ from pygeoapi.api import itemtypes as itemtypes_api from pygeoapi.plugin import load_plugin +from pygeoapi.provider import get_provider_by_type from pygeoapi.provider.base import ( ProviderConnectionError, ProviderNotFoundError, ProviderTypeError ) -from pygeoapi.util import ( - filter_dict_by_key_value, get_current_datetime, get_provider_by_type, - render_j2_template, to_json -) +from pygeoapi.util import (filter_dict_by_key_value, get_current_datetime, + render_j2_template, to_json) from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML diff --git a/pygeoapi/api/tiles.py b/pygeoapi/api/tiles.py index 2e31a6faf..fb8a39dbb 100644 --- a/pygeoapi/api/tiles.py +++ b/pygeoapi/api/tiles.py @@ -46,15 +46,13 @@ from pygeoapi.plugin import load_plugin from pygeoapi.models.provider.base import (TilesMetadataFormat, TileMatrixSetEnum) +from pygeoapi.provider import get_provider_by_type, filter_providers_by_type from pygeoapi.provider.base import ( ProviderGenericError, ProviderTypeError ) from pygeoapi.provider.tile import ProviderTileNotFoundError -from pygeoapi.util import ( - get_provider_by_type, to_json, filter_dict_by_key_value, - filter_providers_by_type, render_j2_template -) +from pygeoapi.util import to_json, filter_dict_by_key_value, render_j2_template from . import ( APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML, SYSTEM_LOCALE, F_JSONLD diff --git a/pygeoapi/provider/__init__.py b/pygeoapi/provider/__init__.py index 7595c47ca..1ebd319cf 100644 --- a/pygeoapi/provider/__init__.py +++ b/pygeoapi/provider/__init__.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2019 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -27,4 +27,63 @@ # # ================================================================= -"""Provider module containing the plugins wrapping data sources""" +import logging + +from pygeoapi.provider.base import ProviderTypeError + +LOGGER = logging.getLogger(__name__) + + +def filter_providers_by_type(providers: list, type: str) -> dict: + """ + helper function to filter a list of providers by type + + :param providers: ``list`` + :param type: str + + :returns: filtered ``dict`` provider + """ + + providers_ = {provider['type']: provider for provider in providers} + return providers_.get(type) + + +def get_provider_by_type(providers: list, provider_type: str) -> dict: + """ + helper function to load a provider by a provider type + + :param providers: ``list`` of providers + :param provider_type: type of provider (e.g. feature) + + :returns: provider based on type + """ + + LOGGER.debug(f'Searching for provider type {provider_type}') + try: + p = (next(d for i, d in enumerate(providers) + if d['type'] == provider_type)) + except (RuntimeError, StopIteration): + raise ProviderTypeError('Invalid provider type requested') + + return p + + +def get_provider_default(providers: list) -> dict: + """ + helper function to get a resource's default provider + + :param providers: ``list`` of providers + + :returns: filtered ``dict`` + """ + + try: + default = (next(d for i, d in enumerate(providers) if 'default' in d + and d['default'])) + LOGGER.debug('found default provider type') + except StopIteration: + LOGGER.debug('no default provider type. Returning first provider') + default = providers[0] + + LOGGER.debug(f"Default provider: {default['type']}") + return default diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index cba5abaea..631d41c99 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -91,6 +91,7 @@ ProviderQueryError, ProviderItemNotFoundError ) +from pygeoapi.util import str2bool LOGGER = logging.getLogger(__name__) @@ -127,6 +128,7 @@ def __init__( self.id_field = provider_def['id_field'] self.geom = provider_def.get('geom_field', 'geom') self.driver_name = driver_name + self.count = str2bool(provider_def.get('count', True)) LOGGER.debug(f'Name: {self.name}') LOGGER.debug(f'Table: {self.table}') diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 44406e5b5..c91a4c3e0 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -65,8 +65,7 @@ from pygeoapi import l10n from pygeoapi.models import config as config_models from pygeoapi.plugin import load_plugin, PLUGINS -from pygeoapi.provider.base import ProviderTypeError - +from pygeoapi.provider import get_provider_default LOGGER = logging.getLogger(__name__) @@ -527,61 +526,6 @@ def filter_dict_by_key_value(dict_: dict, key: str, value: str) -> dict: return {k: v for (k, v) in dict_.items() if v[key] == value} -def filter_providers_by_type(providers: list, type: str) -> dict: - """ - helper function to filter a list of providers by type - - :param providers: ``list`` - :param type: str - - :returns: filtered ``dict`` provider - """ - - providers_ = {provider['type']: provider for provider in providers} - return providers_.get(type) - - -def get_provider_by_type(providers: list, provider_type: str) -> dict: - """ - helper function to load a provider by a provider type - - :param providers: ``list`` of providers - :param provider_type: type of provider (e.g. feature) - - :returns: provider based on type - """ - - LOGGER.debug(f'Searching for provider type {provider_type}') - try: - p = (next(d for i, d in enumerate(providers) - if d['type'] == provider_type)) - except (RuntimeError, StopIteration): - raise ProviderTypeError('Invalid provider type requested') - - return p - - -def get_provider_default(providers: list) -> dict: - """ - helper function to get a resource's default provider - - :param providers: ``list`` of providers - - :returns: filtered ``dict`` - """ - - try: - default = (next(d for i, d in enumerate(providers) if 'default' in d - and d['default'])) - LOGGER.debug('found default provider type') - except StopIteration: - LOGGER.debug('no default provider type. Returning first provider') - default = providers[0] - - LOGGER.debug(f"Default provider: {default['type']}") - return default - - class ProcessExecutionMode(Enum): sync_execute = 'sync-execute' async_execute = 'async-execute' diff --git a/tests/other/test_util.py b/tests/other/test_util.py index 6d3d70aa6..4e54840c6 100644 --- a/tests/other/test_util.py +++ b/tests/other/test_util.py @@ -38,6 +38,7 @@ from pygeoapi import util from pygeoapi.api import __version__ +from pygeoapi.provider import get_provider_by_type, get_provider_default from pygeoapi.provider.base import ProviderTypeError from ..util import get_test_file_path @@ -181,25 +182,25 @@ def test_filter_dict_by_key_value(config): def test_get_provider_by_type(config): - p = util.get_provider_by_type(config['resources']['obs']['providers'], - 'feature') + p = get_provider_by_type(config['resources']['obs']['providers'], + 'feature') assert isinstance(p, dict) assert p['type'] == 'feature' assert p['name'] == 'CSV' with pytest.raises(ProviderTypeError): - p = util.get_provider_by_type(config['resources']['obs']['providers'], - 'something-else') + p = get_provider_by_type(config['resources']['obs']['providers'], + 'something-else') def test_get_provider_default(config): - pd = util.get_provider_default(config['resources']['obs']['providers']) + pd = get_provider_default(config['resources']['obs']['providers']) assert pd['type'] == 'feature' assert pd['name'] == 'CSV' - pd = util.get_provider_default(config['resources']['obs']['providers']) + pd = get_provider_default(config['resources']['obs']['providers']) def test_read_data(): From e20d7d1ab544047fc007ae8f5e38c704a71fc6a5 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 2 Feb 2026 09:16:24 -0500 Subject: [PATCH 17/41] OAProc: do not pretty print JSON results when document=raw (#2212) (#2230) --- pygeoapi/api/processes.py | 8 ++++++-- tests/api/test_processes.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index d6a477edf..299d2188e 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -519,8 +519,12 @@ def execute_process(api: API, request: APIRequest, else: http_status = HTTPStatus.OK - if mime_type == 'application/json' or requested_response == 'document': - response2 = to_json(response, api.pretty_print) + if mime_type == 'application/json': + if requested_response == 'document': + pretty_print_ = api.pretty_print + else: # raw + pretty_print_ = False + response2 = to_json(response, pretty_print_) else: response2 = response diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index fbe27ed20..3a654b121 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -204,6 +204,11 @@ def test_execute_process(config, api_): }, 'response': 'document' } + req_body_9 = { + 'inputs': { + 'name': 'Test document' + } + } cleanup_jobs = set() @@ -364,6 +369,12 @@ def test_execute_process(config, api_): assert 'outputs' in response assert isinstance(response['outputs'], list) + req = mock_api_request(data=req_body_9) + rsp_headers, code, response = execute_process(api_, req, 'hello-world') + + response2 = '{"id":"echo","value":"Hello Test document!"}' + assert response == response2 + # Cleanup time.sleep(2) # Allow time for any outstanding async jobs for _, job_id in cleanup_jobs: From 5d01f16a273c0c256da65c9d223141c58de3f889 Mon Sep 17 00:00:00 2001 From: francescoingv <9592487+francescoingv@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:00:19 +0100 Subject: [PATCH 18/41] Implement OGC API - Processes, Requirement 25, for jobControlOptions async-execute only, and no user preference (Issue #2231) (#2232) * Implement OGC API - Processes, Requirement 25, for jobControlOptions async-execute only, and no user preference (Issue #2231) * Minor fix --------- Co-authored-by: FrancescoIngv --- pygeoapi/api/processes.py | 4 +-- pygeoapi/process/manager/base.py | 42 +++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 299d2188e..3195530ad 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -14,7 +14,7 @@ # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva # Copyright (c) 2024 Bernhard Mallinger -# Copyright (c) 2024 Francesco Martinelli +# Copyright (c) 2026 Francesco Martinelli # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -528,7 +528,7 @@ def execute_process(api: API, request: APIRequest, else: response2 = response - if execution_mode == RequestedProcessExecutionMode.respond_async: + if (headers.get('Preference-Applied', '') == RequestedProcessExecutionMode.respond_async.value): # noqa LOGGER.debug('Asynchronous mode detected, returning statusInfo') response2 = { 'jobID': job_id, diff --git a/pygeoapi/process/manager/base.py b/pygeoapi/process/manager/base.py index d3d3285b0..159e753f2 100644 --- a/pygeoapi/process/manager/base.py +++ b/pygeoapi/process/manager/base.py @@ -6,7 +6,7 @@ # # Copyright (c) 2024 Tom Kralidis # (c) 2023 Ricardo Garcia Silva -# (c) 2024 Francesco Martinelli +# (c) 2026 Francesco Martinelli # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -393,9 +393,10 @@ def execute_process( 'requested_response': requested_response } + job_control_options = processor.metadata.get( + 'jobControlOptions', []) + if execution_mode == RequestedProcessExecutionMode.respond_async: - job_control_options = processor.metadata.get( - 'jobControlOptions', []) # client wants async - do we support it? process_supports_async = ( ProcessExecutionMode.async_execute.value in job_control_options @@ -414,17 +415,30 @@ def execute_process( 'Preference-Applied': ( RequestedProcessExecutionMode.wait.value) } - elif execution_mode == RequestedProcessExecutionMode.wait: - # client wants sync - pygeoapi implicitly supports sync mode - LOGGER.debug('Synchronous execution') - handler = self._execute_handler_sync - response_headers = { - 'Preference-Applied': RequestedProcessExecutionMode.wait.value} - else: # client has no preference - # according to OAPI - Processes spec we ought to respond with sync - LOGGER.debug('Synchronous execution') - handler = self._execute_handler_sync - response_headers = None + else: # client has no preference or clients wants sync + # do we support sync? + process_supports_sync = ( + ProcessExecutionMode.sync_execute.value in job_control_options + ) + if not process_supports_sync: + LOGGER.debug('Asynchronous execution') + handler = self._execute_handler_async + response_headers = { + 'Preference-Applied': ( + RequestedProcessExecutionMode.respond_async.value) + } + else: + # according to OAPI - Processes spec we ought to + # respond with sync + LOGGER.debug('Synchronous execution') + handler = self._execute_handler_sync + if execution_mode == RequestedProcessExecutionMode.wait: + response_headers = None + else: + response_headers = { + 'Preference-Applied': ( + RequestedProcessExecutionMode.wait.value) + } # Add Job before returning any response. current_status = JobStatus.accepted From 68f550305640b0c43e7f404123e8b0291237638e Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 2 Feb 2026 14:07:35 -0500 Subject: [PATCH 19/41] EDR: add parameter description if exists (#2233) (#2234) --- pygeoapi/api/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index c4bf94fd4..99818996e 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -1335,6 +1335,11 @@ def describe_collections(api: API, request: APIRequest, } } + collection['parameter_names'][key].update({ + 'description': value['description']} + if 'description' in value else {} + ) + for qt in p.get_query_types(): data_query = { 'link': { From 7e47b026440bb5573acda17299e27785ddb32d2d Mon Sep 17 00:00:00 2001 From: Matthew Dear <160152917+mdearos@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:18:06 +0000 Subject: [PATCH 20/41] fix: add fix for slow full count in postgresql (#2174) * feat: implement optional count for postgresql provider This work has been done to allow the result count to be enabled or disabled for the PostgreSQL provider. By disabling the count you can get improved performance on large datasets but on smaller datasets this is unlikely to have any affect. * fix: move count to base provider This work has been done to move count to the base provider. While doing this work I also added a debug log message to state when the count had been disabled in the SQL provider. Also, I removed some tests that were no longer needed after the introduction of the str2bool function, when getting the count value from the configuration file. * fix: convert string true to boolean --- docs/source/publishing/ogcapi-features.rst | 3 ++ pygeoapi/provider/base.py | 3 ++ pygeoapi/provider/sql.py | 14 ++++----- tests/provider/test_postgresql_provider.py | 35 ++++++++++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index 3a1c2e9e5..5646f1a7b 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -625,6 +625,7 @@ Must have PostGIS installed. id_field: osm_id table: hotosm_bdi_waterways geom_field: foo_geom + count: true # Optional; Default true; Enable/disable count for improved performance. A number of database connection options can be also configured in the provider in order to adjust properly the sqlalchemy engine client. These are optional and if not specified, the default from the engine will be used. Please see also `SQLAlchemy docs `_. @@ -662,6 +663,7 @@ These are optional and if not specified, the default from the engine will be use id_field: osm_id table: hotosm_bdi_waterways geom_field: foo_geom + count: true # Optional; Default true; Enable/disable count for improved performance. The PostgreSQL provider is also able to connect to Cloud SQL databases. @@ -677,6 +679,7 @@ The PostgreSQL provider is also able to connect to Cloud SQL databases. password: postgres id_field: id table: states + count: true # Optional; Default true; Enable/disable count for improved performance. This is what a configuration for `Google Cloud SQL`_ connection looks like. The ``host`` block contains the necessary socket connection information. diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py index b1ccb9c43..3dd4c5a18 100644 --- a/pygeoapi/provider/base.py +++ b/pygeoapi/provider/base.py @@ -57,6 +57,8 @@ def __init__(self, provider_def): :returns: pygeoapi.provider.base.BaseProvider """ + from pygeoapi.util import str2bool + try: self.name = provider_def['name'] self.type = provider_def['type'] @@ -65,6 +67,7 @@ def __init__(self, provider_def): raise RuntimeError('name/type/data are required') self.editable = provider_def.get('editable', False) + self.count = str2bool(provider_def.get('count', True)) self.options = provider_def.get('options') self.id_field = provider_def.get('id_field') self.uri_field = provider_def.get('uri_field') diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index 631d41c99..a955f06db 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -91,7 +91,6 @@ ProviderQueryError, ProviderItemNotFoundError ) -from pygeoapi.util import str2bool LOGGER = logging.getLogger(__name__) @@ -128,7 +127,6 @@ def __init__( self.id_field = provider_def['id_field'] self.geom = provider_def.get('geom_field', 'geom') self.driver_name = driver_name - self.count = str2bool(provider_def.get('count', True)) LOGGER.debug(f'Name: {self.name}') LOGGER.debug(f'Table: {self.table}') @@ -214,18 +212,20 @@ def query( .options(selected_properties) ) - matched = results.count() - - LOGGER.debug(f'Found {matched} result(s)') - LOGGER.debug('Preparing response') response = { 'type': 'FeatureCollection', 'features': [], - 'numberMatched': matched, 'numberReturned': 0 } + if self.count or resulttype == 'hits': + matched = results.count() + response['numberMatched'] = matched + LOGGER.debug(f'Found {matched} result(s)') + else: + LOGGER.debug('Count disabled') + if resulttype == 'hits' or not results: return response diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index da3d0f99e..c27660caf 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -908,3 +908,38 @@ def test_transaction_create_handles_invalid_input_data(pg_api_, data): headers, code, content = manage_collection_item( pg_api_, req, action='create', dataset='hot_osm_waterways') assert 'generic error' in content + + +def test_provider_count_default_value(config): + # Arrange + provider = PostgreSQLProvider(config) + + # Act + results = provider.query() + + # Assert + assert results['numberMatched'] == 14776 + + +def test_provider_count_false(config): + # Arrange + config['count'] = 'false' + provider = PostgreSQLProvider(config) + + # Act + results = provider.query() + + # Assert + assert 'numberMatched' not in results + + +def test_provider_count_false_with_resulttype_hits(config): + # Arrange + config['count'] = 'false' + provider = PostgreSQLProvider(config) + + # Act + results = provider.query(resulttype="hits") + + # Assert + assert results['numberMatched'] == 14776 From 19cc61873fb5a05ba0446854d25f5906daf097f9 Mon Sep 17 00:00:00 2001 From: Francesco Bartoli Date: Tue, 10 Feb 2026 14:28:29 +0100 Subject: [PATCH 21/41] Fix to not mutating conformance class list (#2239) * Fix to not mutating conformance class list * Fix flake8 * Move import and update copyright --------- Co-authored-by: Francesco Bartoli --- pygeoapi/api/__init__.py | 4 ++-- tests/api/test_api.py | 41 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 99818996e..9116737a4 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # # Copyright (c) 2026 Tom Kralidis -# Copyright (c) 2025 Francesco Bartoli +# Copyright (c) 2026 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva # @@ -886,7 +886,7 @@ def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: apis_dict = all_apis() - conformance_list = CONFORMANCE_CLASSES + conformance_list = list(CONFORMANCE_CLASSES) for key, value in api.config['resources'].items(): if value['type'] == 'process': diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 0f8e785c1..816fe8178 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -4,9 +4,11 @@ # John A Stevenson # Colin Blackburn # Bernhard Mallinger +# Francesco Bartoli # # Copyright (c) 2024 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn +# Copyright (c) 2026 Francesco Bartoli # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -39,10 +41,10 @@ import pytest from pygeoapi.api import ( - API, APIRequest, FORMAT_TYPES, F_HTML, F_JSON, F_JSONLD, F_GZIP, - __version__, validate_bbox, validate_datetime, evaluate_limit, - validate_subset, landing_page, openapi_, conformance, describe_collections, - get_collection_schema, + API, APIRequest, CONFORMANCE_CLASSES, FORMAT_TYPES, F_HTML, F_JSON, + F_JSONLD, F_GZIP, __version__, validate_bbox, validate_datetime, + evaluate_limit, validate_subset, landing_page, openapi_, conformance, + describe_collections, get_collection_schema, ) from pygeoapi.util import yaml_load, get_api_rules, get_base_url @@ -567,6 +569,37 @@ def test_conformance(config, api_): assert rsp_headers['Content-Language'] == 'en-US' +def test_conformance_does_not_mutate_global_list(config, api_): + """Test conformance method does not mutate CONFORMANCE_CLASSES. + + This test verifies that the global CONFORMANCE_CLASSES list is not + mutated by calls to the conformance function. The base conformance + classes should remain unchanged after multiple calls. + """ + + # Store the original length and content of the global list + original_length = len(CONFORMANCE_CLASSES) + original_classes = list(CONFORMANCE_CLASSES) + + req = mock_api_request() + + # Make multiple calls to conformance + for _ in range(3): + conformance(api_, req) + + # The global list should NOT have been mutated + assert len(CONFORMANCE_CLASSES) == original_length, ( + f'Global CONFORMANCE_CLASSES was mutated! ' + f'Original length: {original_length}, ' + f'Current length: {len(CONFORMANCE_CLASSES)}. ' + f'The conformance() function should create a copy of the list ' + f'before extending it.' + ) + assert CONFORMANCE_CLASSES == original_classes, ( + 'Global CONFORMANCE_CLASSES content was modified' + ) + + def test_describe_collections(config, api_): req = mock_api_request({"f": "html"}) rsp_headers, code, response = describe_collections(api_, req) From 0b53d490f8fafd1592321deacb3cd53df3578c11 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 10 Feb 2026 20:39:01 -0500 Subject: [PATCH 22/41] OAProc: fix gzip compression on async process execution (#2238) (#2240) * OAProc: fix gzip encoding for async process execution (#2238) * support literals for process execution responses * update tests * fix flake8 --- pygeoapi/api/processes.py | 1 + pygeoapi/process/base.py | 2 +- pygeoapi/process/manager/base.py | 7 +++++++ tests/api/test_processes.py | 4 +++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 3195530ad..b51ff8531 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -535,6 +535,7 @@ def execute_process(api: API, request: APIRequest, 'type': 'process', 'status': status.value } + response2 = to_json(response2, pretty_print_) if api.pubsub_client is not None: LOGGER.debug('Publishing message') diff --git a/pygeoapi/process/base.py b/pygeoapi/process/base.py index 87c05a3cd..9e2136476 100644 --- a/pygeoapi/process/base.py +++ b/pygeoapi/process/base.py @@ -82,7 +82,7 @@ def execute(self, data: dict, outputs: Optional[dict] = None The value of any key may be an object and include the property `transmissionMode` - defaults to `value`. :returns: tuple of MIME type and process response - (string or bytes, or dict) + (string, bytes, list or dict) """ raise NotImplementedError() diff --git a/pygeoapi/process/manager/base.py b/pygeoapi/process/manager/base.py index 159e753f2..fadb90fbd 100644 --- a/pygeoapi/process/manager/base.py +++ b/pygeoapi/process/manager/base.py @@ -277,6 +277,9 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str, current_status = JobStatus.running jfmt, outputs = p.execute(data_dict, **extra_execute_parameters) + if isinstance(outputs, bytes): + outputs = outputs.decode('utf-8') + if requested_response == RequestedResponse.document.value: outputs = { 'outputs': [outputs] @@ -299,6 +302,10 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str, mode = 'wb' data = outputs encoding = None + elif isinstance(outputs, str): + mode = 'w' + data = outputs + encoding = None with job_filename.open(mode=mode, encoding=encoding) as fh: fh.write(data) diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index 3a654b121..a4bd3794f 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -331,8 +331,10 @@ def test_execute_process(config, api_): req = mock_api_request(data=req_body_1, HTTP_Prefer='respond-async') rsp_headers, code, response = execute_process(api_, req, 'hello-world') - assert 'Location' in rsp_headers + response = json.loads(response) assert code == HTTPStatus.CREATED + + assert 'Location' in rsp_headers assert isinstance(response, dict) assert 'jobID' in response assert 'type' in response From b4c362cac0d4988319680a10ef63d4718b96dd81 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 16 Feb 2026 10:12:39 -0500 Subject: [PATCH 23/41] update noble to 20260113 in Dockerfile (#2246) Updated copyright year and base image version. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb17435e2..d8947cbea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ # Francesco Bartoli # Angelos Tzotsos # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2019 Just van den Broecke # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2025 Angelos Tzotsos @@ -34,7 +34,7 @@ # # ================================================================= -FROM ubuntu:noble-20251013 +FROM ubuntu:noble-20260113 LABEL maintainer="Just van den Broecke " From c3c3eb86338ec92e9c818882b08f0cbcb6b8f0e1 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 17 Feb 2026 12:17:12 -0500 Subject: [PATCH 24/41] Update Docker API in main GitHub Action (#2248) --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 179453e8b..a9266caec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,6 +24,7 @@ jobs: - python-version: '3.12' env: PYGEOAPI_CONFIG: "$(pwd)/pygeoapi-config.yml" + DOCKER_API_VERSION: "1.44" services: postgres: @@ -42,7 +43,6 @@ jobs: docker pull elasticsearch:8.17.0 & docker pull opensearchproject/opensearch:2.18.0 & docker pull mongo:8.0.4 & - docker pull ghcr.io/cgs-earth/sensorthings-action:0.1.0 & docker pull postgis/postgis:14-3.2 & - name: Clear up GitHub runner diskspace run: | @@ -96,7 +96,7 @@ jobs: with: mongodb-version: '8.0.4' - name: Install and run SensorThingsAPI - uses: cgs-earth/sensorthings-action@v0.1.0 + uses: cgs-earth/sensorthings-action@v0.1.2 - name: Install sqlite and gpkg dependencies uses: awalsh128/cache-apt-pkgs-action@v1.4.3 with: From 66876aba680028db026576f9b24341b3f7c38ea1 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 17 Feb 2026 14:17:43 -0500 Subject: [PATCH 25/41] fix form reset for records items HTML (#2247) --- .../templates/collections/items/index.html | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/pygeoapi/templates/collections/items/index.html b/pygeoapi/templates/collections/items/index.html index e2cabb827..5db56046d 100644 --- a/pygeoapi/templates/collections/items/index.html +++ b/pygeoapi/templates/collections/items/index.html @@ -33,28 +33,30 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en {% if data['itemtype'] == 'record' %}
-
-
- - -
-
- - -
-
- - +
+
+
+ + +
+
+ + +
+
+ + +
-
-
-
- - - - +
+
+ + + + +
-
+
{% endif %} @@ -205,12 +207,12 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en if (q) { query_string.push('q=' + encodeURIComponent(q)); } - if (datetime_begin) { + if (datetime_begin !== "") { datetime.push(datetime_begin + 'T00:00:00Z'); } else { datetime.push('..'); } - if (datetime_end) { + if (datetime_end !== "") { datetime.push(datetime_end + 'T23:59:59Z'); } else { datetime.push('..'); From 096c5778aad18dbb7eeadd18720d2dd2b1217873 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 22 Feb 2026 21:20:28 -0500 Subject: [PATCH 26/41] add resolution/default to collection extents.temporal configuration (#2260) (#2261) --- docs/source/configuration.rst | 2 ++ pygeoapi/api/__init__.py | 6 ++++++ pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml | 6 ++++++ tests/api/test_api.py | 8 ++++++-- tests/pygeoapi-test-config.yml | 4 +++- 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 8cab94f52..d718e85e6 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -243,6 +243,8 @@ default. begin: 2000-10-30T18:24:39Z # start datetime in RFC3339 end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS + resolution: P1D # ISO 8601 duration + default: 2000-10-30T18:24:39Z # default time # additional extents can be added as desired (1..n) foo: url: https://example.org/def # required URL of the extent diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 9116737a4..55a1d4e1f 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -1008,6 +1008,12 @@ def describe_collections(api: API, request: APIRequest, } if 'trs' in t_ext: collection['extent']['temporal']['trs'] = t_ext['trs'] + if 'resolution' in t_ext: + collection['extent']['temporal']['grid'] = { + 'resolution': t_ext['resolution'] + } + if 'default' in t_ext: + collection['extent']['temporal']['default'] = t_ext['default'] _ = extents.pop('spatial', None) _ = extents.pop('temporal', None) diff --git a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml index d772f000b..19c18f5dc 100644 --- a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml @@ -477,6 +477,12 @@ properties: type: string description: temporal reference system of features default: 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' + resolution: + type: string + description: temporal resolution + default: + type: string + description: default time value patternProperties: "^(?!spatial$|temporal$).*": type: object diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 816fe8178..763fdc77d 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -6,7 +6,7 @@ # Bernhard Mallinger # Francesco Bartoli # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2026 Francesco Bartoli # @@ -635,7 +635,11 @@ def test_describe_collections(config, api_): 'interval': [ ['2000-10-30T18:24:39+00:00', '2007-10-30T08:57:29+00:00'] ], - 'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' + 'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian', + 'grid': { + 'resolution': 'P1D' + }, + 'default': '2000-10-30T18:24:39+00:00' } } diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 13dc63aa9..e64afbf28 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2019 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -142,6 +142,8 @@ resources: begin: 2000-10-30T18:24:39Z end: 2007-10-30T08:57:29Z trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + resolution: P1D + default: 2000-10-30T18:24:39Z providers: - type: feature name: CSV From dabbea77ee96843c90e1931ebefb4623859ef9e8 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 22 Feb 2026 22:15:21 -0500 Subject: [PATCH 27/41] remove deprecated license and fix test cmdclass in setup.py (#2262) --- setup.py | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/setup.py b/setup.py index 161941e21..efd7dd99c 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -84,26 +84,7 @@ def finalize_options(self): def run(self): import subprocess - errno = subprocess.call(['pytest', 'tests/test_api.py']) - raise SystemExit(errno) - - -class PyCoverage(Command): - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - import subprocess - - errno = subprocess.call(['coverage', 'run', '--source=pygeoapi', - '-m', 'unittest', - 'pygeoapi.tests.run_tests']) - errno = subprocess.call(['coverage', 'report', '-m']) + errno = subprocess.call(['pytest', 'tests/api/test_api.py']) raise SystemExit(errno) @@ -169,14 +150,12 @@ def get_package_version(): 'Environment :: Console', 'Intended Audience :: Developers', 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Scientific/Engineering :: GIS' ], cmdclass={ 'test': PyTest, - 'coverage': PyCoverage, 'cleanbuild': PyCleanBuild } ) From 42798417886eb0f9a0de8cdb978747b2f8dcc07f Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:01:30 -0500 Subject: [PATCH 28/41] Support SQL connection string (#2251) * Support SQL connection string Modify SQL provider logic to support connection string * Add test for MySQL * Fix flake8 * Remove pg8000 test * Add funcstrings * Synchronize Postgres manager * Update LOGGER statements --- docs/source/publishing/ogcapi-features.rst | 12 ++ pygeoapi/process/manager/postgresql.py | 45 +++---- pygeoapi/provider/sql.py | 139 +++++++++++++------- tests/provider/test_mysql_provider.py | 64 ++++----- tests/provider/test_postgresql_provider.py | 144 +++++++++++++-------- 5 files changed, 246 insertions(+), 158 deletions(-) diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index 5646f1a7b..df40d27bd 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -627,6 +627,18 @@ Must have PostGIS installed. geom_field: foo_geom count: true # Optional; Default true; Enable/disable count for improved performance. +This can be represented as a connection dictionary or as a connection string as follows: + +.. code-block:: yaml + + providers: + - type: feature + name: PostgreSQL + data: postgresql://postgres:postgres@127.0.0.1:3010/test + id_field: osm_id + table: hotosm_bdi_waterways + geom_field: foo_geom + A number of database connection options can be also configured in the provider in order to adjust properly the sqlalchemy engine client. These are optional and if not specified, the default from the engine will be used. Please see also `SQLAlchemy docs `_. diff --git a/pygeoapi/process/manager/postgresql.py b/pygeoapi/process/manager/postgresql.py index bf5033eef..82ed5eb91 100644 --- a/pygeoapi/process/manager/postgresql.py +++ b/pygeoapi/process/manager/postgresql.py @@ -46,7 +46,6 @@ from typing import Any, Tuple from sqlalchemy import insert, update, delete -from sqlalchemy.engine import make_url from sqlalchemy.orm import Session from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD @@ -56,7 +55,9 @@ ProcessorGenericError ) from pygeoapi.process.manager.base import BaseManager -from pygeoapi.provider.sql import get_engine, get_table_model +from pygeoapi.provider.sql import ( + get_engine, get_table_model, store_db_parameters +) from pygeoapi.util import JobStatus @@ -66,13 +67,15 @@ class PostgreSQLManager(BaseManager): """PostgreSQL Manager""" + default_port = 5432 + def __init__(self, manager_def: dict): """ Initialize object :param manager_def: manager definition - :returns: `pygeoapi.process.manager.postgresqs.PostgreSQLManager` + :returns: `pygeoapi.process.manager.postgresql.PostgreSQLManager` """ super().__init__(manager_def) @@ -81,30 +84,18 @@ def __init__(self, manager_def: dict): self.supports_subscribing = True self.connection = manager_def['connection'] - try: - self.db_search_path = tuple(self.connection.get('search_path', - ['public'])) - except Exception: - self.db_search_path = ('public',) - - try: - LOGGER.debug('Connecting to database') - if isinstance(self.connection, str): - _url = make_url(self.connection) - self._engine = get_engine( - 'postgresql+psycopg2', - _url.host, - _url.port, - _url.database, - _url.username, - _url.password) - else: - self._engine = get_engine('postgresql+psycopg2', - **self.connection) - except Exception as err: - msg = 'Test connecting to DB failed' - LOGGER.error(f'{msg}: {err}') - raise ProcessorGenericError(msg) + options = manager_def.get('options', {}) + store_db_parameters(self, manager_def['connection'], options) + self._engine = get_engine( + 'postgresql+psycopg2', + self.db_host, + self.db_port, + self.db_name, + self.db_user, + self._db_password, + self.db_conn, + **self.db_options + ) try: LOGGER.debug('Getting table model') diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index a955f06db..19cc35ca8 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -39,25 +39,12 @@ # # ================================================================= -# Testing local postgis with docker: -# docker run --name "postgis" \ -# -v postgres_data:/var/lib/postgresql -p 5432:5432 \ -# -e ALLOW_IP_RANGE=0.0.0.0/0 \ -# -e POSTGRES_USER=postgres \ -# -e POSTGRES_PASS=postgres \ -# -e POSTGRES_DBNAME=test \ -# -d -t kartoza/postgis - -# Import dump: -# gunzip < tests/data/hotosm_bdi_waterways.sql.gz | -# psql -U postgres -h 127.0.0.1 -p 5432 test - from copy import deepcopy from datetime import datetime from decimal import Decimal import functools import logging -from typing import Optional +from typing import Optional, Any from geoalchemy2 import Geometry # noqa - this isn't used explicitly but is needed to process Geometry columns from geoalchemy2.functions import ST_MakeEnvelope, ST_Intersects @@ -73,7 +60,7 @@ desc, delete ) -from sqlalchemy.engine import URL +from sqlalchemy.engine import URL, Engine from sqlalchemy.exc import ( ConstraintColumnNotFoundError, InvalidRequestError, @@ -82,6 +69,7 @@ from sqlalchemy.ext.automap import automap_base from sqlalchemy.orm import Session, load_only from sqlalchemy.sql.expression import and_ +from sqlalchemy.schema import Table from pygeoapi.crs import get_transform_from_spec, get_srid from pygeoapi.provider.base import ( @@ -135,8 +123,8 @@ def __init__( LOGGER.debug(f'Configured Storage CRS: {self.storage_crs}') # Read table information from database - options = provider_def.get('options', {}) - self._store_db_parameters(provider_def['data'], options) + options = provider_def.get('options', {}) | extra_conn_args + store_db_parameters(self, provider_def['data'], options) self._engine = get_engine( driver_name, self.db_host, @@ -144,13 +132,13 @@ def __init__( self.db_name, self.db_user, self._db_password, - **self.db_options | extra_conn_args + self.db_conn, + **self.db_options ) self.table_model = get_table_model( self.table, self.id_field, self.db_search_path, self._engine ) - LOGGER.debug(f'DB connection: {repr(self._engine.url)}') self.get_fields() def query( @@ -426,22 +414,6 @@ def delete(self, identifier): return result.rowcount > 0 - def _store_db_parameters(self, parameters, options): - self.db_user = parameters.get('user') - self.db_host = parameters.get('host') - self.db_port = parameters.get('port', self.default_port) - self.db_name = parameters.get('dbname') - # db_search_path gets converted to a tuple here in order to ensure it - # is hashable - which allows us to use functools.cache() when - # reflecting the table definition from the DB - self.db_search_path = tuple(parameters.get('search_path', ['public'])) - self._db_password = parameters.get('password') - self.db_options = { - k: v - for k, v in options.items() - if not isinstance(v, dict) - } - def _sqlalchemy_to_feature(self, item, crs_transform_out=None, select_properties=[]): """ @@ -602,6 +574,48 @@ def _select_properties_clause(self, select_properties, skip_geometry): return selected_properties_clause +def store_db_parameters( + self: GenericSQLProvider | Any, + connection_data: str | dict[str], + options: dict[str, str] +) -> None: + """ + Store database connection parameters + + :self: instance of provider or manager class + :param connection_data: connection string or dict of connection params + :param options: additional connection options + + :returns: None + """ + if isinstance(connection_data, str): + self.db_conn = connection_data + connection_data = {} + else: + self.db_conn = None + # OR + self.db_user = connection_data.get('user') + self.db_host = connection_data.get('host') + self.db_port = connection_data.get('port', self.default_port) + self.db_name = ( + connection_data.get('dbname') or connection_data.get('database') + ) + self.db_query = connection_data.get('query') + self._db_password = connection_data.get('password') + # db_search_path gets converted to a tuple here in order to ensure it + # is hashable - which allows us to use functools.cache() when + # reflecting the table definition from the DB + self.db_search_path = tuple( + connection_data.get('search_path') or + options.pop('search_path', ['public']) + ) + self.db_options = { + k: v + for k, v in options.items() + if not isinstance(v, dict) + } + + @functools.cache def get_engine( driver_name: str, @@ -610,20 +624,38 @@ def get_engine( database: str, user: str, password: str, + conn_str: Optional[str] = None, **connect_args -): - """Create SQL Alchemy engine.""" - conn_str = URL.create( - drivername=driver_name, - username=user, - password=password, - host=host, - port=int(port), - database=database - ) +) -> Engine: + """ + Get SQL Alchemy engine. + + :param driver_name: database driver name + :param host: database host + :param port: database port + :param database: database name + :param user: database user + :param password: database password + :param conn_str: optional connection URL + :param connect_args: custom connection arguments to pass to create_engine() + + :returns: SQL Alchemy engine + """ + if conn_str is None: + conn_str = URL.create( + drivername=driver_name, + username=user, + password=password, + host=host, + port=int(port), + database=database + ) + engine = create_engine( conn_str, connect_args=connect_args, pool_pre_ping=True ) + + LOGGER.debug(f'Created engine for {repr(engine.url)}.') return engine @@ -632,14 +664,25 @@ def get_table_model( table_name: str, id_field: str, db_search_path: tuple[str], - engine -): - """Reflect table.""" + engine: Engine +) -> Table: + """ + Reflect table using SQLAlchemy Automap. + + :param table_name: name of table to reflect + :param id_field: name of primary key field + :param db_search_path: tuple of database schemas to search for the table + :param engine: SQLAlchemy engine to use for reflection + + :returns: SQLAlchemy model of the reflected table + """ + LOGGER.debug('Reflecting table definition from database') metadata = MetaData() # Look for table in the first schema in the search path schema = db_search_path[0] try: + LOGGER.debug(f'Looking for table {table_name} in schema {schema}') metadata.reflect( bind=engine, schema=schema, only=[table_name], views=True ) diff --git a/tests/provider/test_mysql_provider.py b/tests/provider/test_mysql_provider.py index 0f470d750..1ac10e1c1 100644 --- a/tests/provider/test_mysql_provider.py +++ b/tests/provider/test_mysql_provider.py @@ -37,44 +37,45 @@ PASSWORD = os.environ.get('MYSQL_PASSWORD', 'mysql') -""" -For local testing, a MySQL database can be spun up with docker -compose as follows: - -services: - - mysql: - image: mysql:8 - ports: - - 3306:3306 - environment: - MYSQL_ROOT_PASSWORD: mysql - MYSQL_USER: pygeoapi - MYSQL_PASSWORD: mysql - MYSQL_DATABASE: test_geo_app - volumes: - - ./tests/data/mysql_data.sql:/docker-entrypoint-initdb.d/init.sql:ro -""" - - -@pytest.fixture() -def config(): - return { +# Testing local MySQL with docker: +''' +docker run --name mysql-test \ + -e MYSQL_ROOT_PASSWORD=mysql \ + -e MYSQL_USER=pygeoapi \ + -e MYSQL_PASSWORD=mysql \ + -e MYSQL_DATABASE=test_geo_app \ + -p 3306:3306 \ + -v ./tests/data/mysql_data.sql:/docker-entrypoint-initdb.d/init.sql:ro \ + -d mysql:8 +''' + + +@pytest.fixture(params=['default', 'connection_string']) +def config(request): + config_ = { 'name': 'MySQL', 'type': 'feature', - 'data': { + 'options': {'connect_timeout': 10}, + 'id_field': 'locationID', + 'table': 'location', + 'geom_field': 'locationCoordinates' + } + if request.param == 'default': + config_['data'] = { 'host': 'localhost', 'dbname': 'test_geo_app', 'user': 'root', 'port': 3306, 'password': PASSWORD, 'search_path': ['test_geo_app'] - }, - 'options': {'connect_timeout': 10}, - 'id_field': 'locationID', - 'table': 'location', - 'geom_field': 'locationCoordinates' - } + } + elif request.param == 'connection_string': + config_['data'] = ( + f'mysql+pymysql://root:{PASSWORD}@localhost:3306/test_geo_app' + ) + config_['options']['search_path'] = ['test_geo_app'] + + return config_ def test_valid_connection_options(config): @@ -87,7 +88,8 @@ def test_valid_connection_options(config): 'keepalives', 'keepalives_idle', 'keepalives_count', - 'keepalives_interval' + 'keepalives_interval', + 'search_path' ] diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index c27660caf..eb0b8760c 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -37,7 +37,17 @@ # ================================================================= # Needs to be run like: python3 -m pytest -# See pygeoapi/provider/postgresql.py for instructions on setting up +# Testing local postgis with docker: +''' +docker run --name postgis \ + --rm \ + -p 5432:5432 \ + -e ALLOW_IP_RANGE=0.0.0.0/0 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASS=postgres \ + -e POSTGRES_DBNAME=test \ + -d -t kartoza/postgis +''' # test database in Docker from http import HTTPStatus @@ -69,44 +79,58 @@ PASSWORD = os.environ.get('POSTGRESQL_PASSWORD', 'postgres') -@pytest.fixture() -def config(): - return { +@pytest.fixture(params=['default', 'connection_string']) +def config(request): + config_ = { 'name': 'PostgreSQL', 'type': 'feature', - 'data': {'host': '127.0.0.1', - 'dbname': 'test', - 'user': 'postgres', - 'password': PASSWORD, - 'search_path': ['osm', 'public'] - }, - 'options': { - 'connect_timeout': 10 - }, + 'options': {'connect_timeout': 10}, 'id_field': 'osm_id', 'table': 'hotosm_bdi_waterways', 'geom_field': 'foo_geom' } + if request.param == 'default': + config_['data'] = { + 'host': '127.0.0.1', + 'dbname': 'test', + 'user': 'postgres', + 'password': PASSWORD, + 'search_path': ['osm', 'public'] + } + elif request.param == 'connection_string': + config_['data'] = ( + f'postgresql://postgres:{PASSWORD}@127.0.0.1:5432/test' + ) + config_['options']['search_path'] = ['osm', 'public'] + return config_ -@pytest.fixture() -def config_types(): - return { + +@pytest.fixture(params=['default', 'connection_string']) +def config_types(request): + config_ = { 'name': 'PostgreSQL', 'type': 'feature', - 'data': {'host': '127.0.0.1', - 'dbname': 'test', - 'user': 'postgres', - 'password': PASSWORD, - 'search_path': ['public'] - }, - 'options': { - 'connect_timeout': 10 - }, + 'options': {'connect_timeout': 10}, 'id_field': 'id', 'table': 'foo', 'geom_field': 'the_geom' } + if request.param == 'default': + config_['data'] = { + 'host': '127.0.0.1', + 'dbname': 'test', + 'user': 'postgres', + 'password': PASSWORD, + 'search_path': ['public', 'osm'] + } + elif request.param == 'connection_string': + config_['data'] = ( + f'postgresql://postgres:{PASSWORD}@127.0.0.1:5432/test' + ) + config_['options']['search_path'] = ['public', 'osm'] + + return config_ @pytest.fixture() @@ -148,14 +172,20 @@ def test_valid_connection_options(config): for key in keys: assert key in ['connect_timeout', 'tcp_user_timeout', 'keepalives', 'keepalives_idle', 'keepalives_count', - 'keepalives_interval'] + 'keepalives_interval', 'search_path'] def test_schema_path_search(config): - config['data']['search_path'] = ['public', 'osm'] + if isinstance(config['data'], dict): + config['data']['search_path'] = ['public', 'osm'] + else: + config['options']['search_path'] = ['public', 'osm'] PostgreSQLProvider(config) - config['data']['search_path'] = ['public', 'notosm'] + if isinstance(config['data'], dict): + config['data']['search_path'] = ['public', 'notosm'] + else: + config['options']['search_path'] = ['public', 'notosm'] with pytest.raises(ProviderQueryError): PostgreSQLProvider(config) @@ -189,13 +219,13 @@ def test_query_materialised_view(config): provider = PostgreSQLProvider(config_materialised_view) # Only ID, width and depth properties should be available - assert set(provider.get_fields().keys()) == {"osm_id", "width", "depth"} + assert set(provider.get_fields().keys()) == {'osm_id', 'width', 'depth'} def test_query_with_property_filter(config): """Test query valid features when filtering by property""" p = PostgreSQLProvider(config) - feature_collection = p.query(properties=[("waterway", "stream")]) + feature_collection = p.query(properties=[('waterway', 'stream')]) features = feature_collection.get('features') stream_features = list( filter(lambda feature: feature['properties']['waterway'] == 'stream', @@ -246,19 +276,19 @@ def test_query_with_config_properties(config): feature = result.get('features')[0] properties = feature.get('properties') for property_name in properties.keys(): - assert property_name in config["properties"] + assert property_name in config['properties'] -@pytest.mark.parametrize("property_filter, expected", [ +@pytest.mark.parametrize('property_filter, expected', [ ([], 14776), - ([("waterway", "stream")], 13930), - ([("waterway", "this does not exist")], 0), + ([('waterway', 'stream')], 13930), + ([('waterway', 'this does not exist')], 0), ]) def test_query_hits_with_property_filter(config, property_filter, expected): """Test query resulttype=hits""" provider = PostgreSQLProvider(config) - results = provider.query(properties=property_filter, resulttype="hits") - assert results["numberMatched"] == expected + results = provider.query(properties=property_filter, resulttype='hits') + assert results['numberMatched'] == expected def test_query_bbox(config): @@ -337,7 +367,7 @@ def test_get_with_config_properties(config): result = provider.get(80835483) properties = result.get('properties') for property_name in properties.keys(): - assert property_name in config["properties"] + assert property_name in config['properties'] def test_get_not_existing_item_raise_exception(config): @@ -376,7 +406,7 @@ def test_query_cql(config, cql, expected_ids): assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') - ids = [feature["id"] for feature in features] + ids = [feature['id'] for feature in features] assert ids == expected_ids @@ -385,7 +415,7 @@ def test_query_cql_properties_bbox_filters(config): # Arrange properties = [('waterway', 'stream')] bbox = [29, -2.8, 29.2, -2.9] - filterq = parse("osm_id BETWEEN 80800000 AND 80900000") + filterq = parse('osm_id BETWEEN 80800000 AND 80900000') expected_ids = [80835470] # Act @@ -395,7 +425,7 @@ def test_query_cql_properties_bbox_filters(config): bbox=bbox) # Assert - ids = [feature["id"] for feature in feature_collection.get('features')] + ids = [feature['id'] for feature in feature_collection.get('features')] assert ids == expected_ids @@ -457,9 +487,9 @@ def test_instantiation(config): provider = PostgreSQLProvider(config) # Assert - assert provider.name == "PostgreSQL" - assert provider.table == "hotosm_bdi_waterways" - assert provider.id_field == "osm_id" + assert provider.name == 'PostgreSQL' + assert provider.table == 'hotosm_bdi_waterways' + assert provider.id_field == 'osm_id' @pytest.mark.parametrize('bad_data, exception, match', [ @@ -484,8 +514,14 @@ def test_instantiation_with_bad_config(config, bad_data, exception, match): def test_instantiation_with_bad_credentials(config): # Arrange - config['data'].update({'user': 'bad_user'}) - match = r'Could not connect to .*bad_user:\*\*\*@' + if isinstance(config['data'], dict): + config['data'].update({'user': 'bad_user'}) + match = r'Could not connect to .*bad_user:\*\*\*@' + + else: + config['data'] = config['data'].replace('postgres:', 'bad_user:') + match = r'Could not connect to .*bad_user:\*\*\*@' + # Make sure we don't use a cached connection in the tests postgresql_provider_module._ENGINE_STORE = {} @@ -505,7 +541,7 @@ def test_engine_and_table_model_stores(config): # Same database connection details, but different table different_table = config.copy() - different_table.update(table="hotosm_bdi_drains") + different_table.update(table='hotosm_bdi_drains') provider2 = PostgreSQLProvider(different_table) assert repr(provider2._engine) == repr(provider0._engine) assert provider2._engine is provider0._engine @@ -515,7 +551,11 @@ def test_engine_and_table_model_stores(config): # and also a different table_model, as two databases may have different # tables with the same name different_host = config.copy() - different_host["data"]["host"] = "localhost" + if isinstance(config['data'], dict): + different_host['data']['host'] = 'localhost' + else: + different_host['data'] = config['data'].replace( + '127.0.0.1', 'localhost') provider3 = PostgreSQLProvider(different_host) assert provider3._engine is not provider0._engine assert provider3.table_model is not provider0.table_model @@ -584,7 +624,7 @@ def test_get_collection_items_postgresql_cql_invalid_filter_language(pg_api_): assert error_response['description'] == 'Invalid filter language' -@pytest.mark.parametrize("bad_cql", [ +@pytest.mark.parametrize('bad_cql', [ 'id IN (1, ~)', 'id EATS (1, 2)', # Valid CQL relations only 'id IN (1, 2' # At some point this may return UnexpectedEOF @@ -664,7 +704,7 @@ def test_get_collection_items_postgresql_cql_json_invalid_filter_language(pg_api """ # Arrange # CQL should never be parsed - cql = {"in": {"value": {"property": "id"}, "list": [1, 2]}} + cql = {'in': {'value': {'property': 'id'}, 'list': [1, 2]}} headers = {'CONTENT_TYPE': 'application/query-cql-json'} # Act @@ -681,9 +721,9 @@ def test_get_collection_items_postgresql_cql_json_invalid_filter_language(pg_api assert error_response['description'] == 'Bad CQL JSON' -@pytest.mark.parametrize("bad_cql", [ +@pytest.mark.parametrize('bad_cql', [ # Valid CQL relations only - {"eats": {"value": {"property": "id"}, "list": [1, 2]}}, + {'eats': {'value': {'property': 'id'}, 'list': [1, 2]}}, # At some point this may return UnexpectedEOF '{"in": {"value": {"property": "id"}, "list": [1, 2}}' ]) @@ -939,7 +979,7 @@ def test_provider_count_false_with_resulttype_hits(config): provider = PostgreSQLProvider(config) # Act - results = provider.query(resulttype="hits") + results = provider.query(resulttype='hits') # Assert assert results['numberMatched'] == 14776 From b777b16d81670fca43a75db2f04d5f802bbf5155 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 24 Feb 2026 08:36:52 -0500 Subject: [PATCH 29/41] PubSub: add support for Kafka (#2259) (#2258) --- docs/source/pubsub.rst | 27 ++++++++++ pygeoapi/plugin.py | 1 + pygeoapi/pubsub/http.py | 4 +- pygeoapi/pubsub/kafka.py | 109 +++++++++++++++++++++++++++++++++++++++ pygeoapi/pubsub/mqtt.py | 4 +- requirements-pubsub.txt | 1 + 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 pygeoapi/pubsub/kafka.py diff --git a/docs/source/pubsub.rst b/docs/source/pubsub.rst index da8570f97..739a50d93 100644 --- a/docs/source/pubsub.rst +++ b/docs/source/pubsub.rst @@ -85,6 +85,11 @@ Brokers The following protocols are supported: +.. note:: + + Pub/Sub client dependencies will vary based on the selected broker. ``requirements-pubsub.txt`` contains all requirements for supported brokers, as a reference point. + + MQTT ^^^^ @@ -99,6 +104,23 @@ Example directive: channel: messages/a/data # optional hidden: false # default +Kafka +^^^^^ + +Example directive: + +.. code-block:: yaml + + pubsub: + name: Kafka + broker: + url: tcp://localhost:9092 + channel: messages-a-data + # if using authentication: + # sasl_mechanism: PLAIN # default PLAIN + # sasl_security_protocol: SASL_PLAINTEXT # default SASL_PLAINTEXT + hidden: true # default false + HTTP ^^^^ @@ -113,12 +135,16 @@ Example directive: channel: messages-a-data # optional hidden: true # default false +Additional information +---------------------- + .. note:: For any Pub/Sub endpoints requiring authentication, encode the ``url`` value as follows: * ``mqtt://username:password@localhost:1883`` * ``https://username:password@localhost`` + * ``tcp://username:password@localhost:9092`` As with any section of the pygeoapi configuration, environment variables may be used as needed, for example to set username/password information in a URL. If ``pubsub.broker.url`` contains authentication, and @@ -131,5 +157,6 @@ Example directive: If a ``channel`` is not defined, only the relevant OGC API endpoint is used. + .. _`OGC API Publish-Subscribe Workflow - Part 1: Core`: https://docs.ogc.org/DRAFTS/25-030.html .. _`AsyncAPI`: https://www.asyncapi.com diff --git a/pygeoapi/plugin.py b/pygeoapi/plugin.py index 19795be15..32292c895 100644 --- a/pygeoapi/plugin.py +++ b/pygeoapi/plugin.py @@ -88,6 +88,7 @@ }, 'pubsub': { 'HTTP': 'pygeoapi.pubsub.http.HTTPPubSubClient', + 'Kafka': 'pygeoapi.pubsub.kafka.KafkaPubSubClient', 'MQTT': 'pygeoapi.pubsub.mqtt.MQTTPubSubClient' } } diff --git a/pygeoapi/pubsub/http.py b/pygeoapi/pubsub/http.py index a07c600ca..c19accc7d 100644 --- a/pygeoapi/pubsub/http.py +++ b/pygeoapi/pubsub/http.py @@ -41,7 +41,7 @@ class HTTPPubSubClient(BasePubSubClient): """HTTP client""" - def __init__(self, broker_url): + def __init__(self, publisher_def): """ Initialize object @@ -50,7 +50,7 @@ def __init__(self, broker_url): :returns: pygeoapi.pubsub.http.HTTPPubSubClient """ - super().__init__(broker_url) + super().__init__(publisher_def) self.name = 'HTTP' self.type = 'http' self.auth = None diff --git a/pygeoapi/pubsub/kafka.py b/pygeoapi/pubsub/kafka.py new file mode 100644 index 000000000..20033dea8 --- /dev/null +++ b/pygeoapi/pubsub/kafka.py @@ -0,0 +1,109 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging + +from kafka import errors, KafkaProducer + +from pygeoapi.pubsub.base import BasePubSubClient, PubSubClientConnectionError +from pygeoapi.util import to_json + +LOGGER = logging.getLogger(__name__) + + +class KafkaPubSubClient(BasePubSubClient): + """Kafka client""" + + def __init__(self, publisher_def): + """ + Initialize object + + :param publisher_def: provider definition + + :returns: pygeoapi.pubsub.kafka.KafkaPubSubClient + """ + + super().__init__(publisher_def) + self.name = 'Kafka' + self.type = 'kafka' + self.sasl_mechanism = publisher_def.get('sasl.mechanism', 'PLAIN') + self.security_protocol = publisher_def.get('security.protocol', 'SASL_SSL') # noqa + + msg = f'Initializing to broker {self.broker_safe_url} with id {self.client_id}' # noqa + LOGGER.debug(msg) + + def connect(self) -> None: + """ + Connect to an Kafka broker + + :returns: None + """ + + args = { + 'bootstrap_servers': f'{self.broker_url.hostname}:{self.broker_url.port}', # noqa + 'client_id': self.client_id, + 'value_serializer': lambda v: to_json(v).encode('utf-8') + } + if None not in [self.broker_url.username, self.broker_url.password]: + args.update({ + 'security.protocol': self.security_protocol, + 'sasl.mechanism': self.sasl_mechanism, + 'sasl.username': self.broker_url.username, + 'sasl.password': self.broker_url.password + }) + + LOGGER.debug('Creating Kafka producer') + try: + self.producer = KafkaProducer(**args) + except errors.NoBrokersAvailable as err: + raise PubSubClientConnectionError(err) + + def pub(self, channel: str, message: str) -> bool: + """ + Publish a message to a broker/channel + + :param channel: `str` of topic + :param message: `str` of message + + :returns: `bool` of publish result + """ + + LOGGER.debug(f'Publishing to broker {self.broker_safe_url}') + LOGGER.debug(f'Channel: {channel}') + LOGGER.debug(f'Message: {message}') + LOGGER.debug('Sanitizing channel for HTTP') + channel = channel.replace('/', '-') + channel = channel.replace(':', '-') + LOGGER.debug(f'Sanitized channel for Kafka: {channel}') + + self.producer.send(channel, value=message) + self.producer.flush() + + def __repr__(self): + return f' {self.broker_safe_url}' diff --git a/pygeoapi/pubsub/mqtt.py b/pygeoapi/pubsub/mqtt.py index 2afd04087..0f88d670b 100644 --- a/pygeoapi/pubsub/mqtt.py +++ b/pygeoapi/pubsub/mqtt.py @@ -39,7 +39,7 @@ class MQTTPubSubClient(BasePubSubClient): """MQTT client""" - def __init__(self, broker_url): + def __init__(self, publisher_def): """ Initialize object @@ -48,7 +48,7 @@ def __init__(self, broker_url): :returns: pycsw.pubsub.mqtt.MQTTPubSubClient """ - super().__init__(broker_url) + super().__init__(publisher_def) self.type = 'mqtt' self.port = self.broker_url.port diff --git a/requirements-pubsub.txt b/requirements-pubsub.txt index 8579e8b22..1e32c725e 100644 --- a/requirements-pubsub.txt +++ b/requirements-pubsub.txt @@ -1 +1,2 @@ +kafka-python paho-mqtt From e4d8ced809533c8aee7f22f5d850a9f6bd3aa5ac Mon Sep 17 00:00:00 2001 From: Colton Loftus <70598503+C-Loftus@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:35:33 -0500 Subject: [PATCH 30/41] Fix `transform_bbox` CRS types (#2265) * Fix transform_bbox CRS types * manually fix formatting * manually fix formatting --- pygeoapi/crs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index 0c2ff7b48..188414fa1 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -278,7 +278,8 @@ def crs_transform_feature(feature: dict, transform_func: Callable): ) -def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: +def transform_bbox(bbox: list, from_crs: Union[str, pyproj.CRS], + to_crs: Union[str, pyproj.CRS]) -> list: """ helper function to transform a bounding box (bbox) from a source to a target CRS. CRSs in URI str format. @@ -286,7 +287,7 @@ def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: :param bbox: list of coordinates in 'from_crs' projection :param from_crs: CRS to transform from - :param to_crs: CRSto transform to + :param to_crs: CRS to transform to :raises `CRSError`: Error raised if no CRS could be identified from an URI. From 247ebf775db61efb73e27daed977390d92a44f3d Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 1 Mar 2026 15:00:15 -0500 Subject: [PATCH 31/41] prevent form fields from being submitted on form click/submit (#2263) --- pygeoapi/templates/collections/items/index.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pygeoapi/templates/collections/items/index.html b/pygeoapi/templates/collections/items/index.html index 5db56046d..3d325c445 100644 --- a/pygeoapi/templates/collections/items/index.html +++ b/pygeoapi/templates/collections/items/index.html @@ -52,7 +52,7 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en
- +

@@ -192,6 +192,7 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en document.getElementById("q").addEventListener("keydown", function(event) { if (event.key === "Enter") { + event.preventDefault(); submitForm(); } }); @@ -288,6 +289,9 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en setRectangle(map.getBounds().pad(-0.95)); } } + + var form = document.getElementById("searchForm"); + form.addEventListener("submit", submitForm); {% endif %} {% endif %} From a09fdac6dd5c97bdad9484355592748b1b88ef70 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 2 Mar 2026 11:18:00 -0500 Subject: [PATCH 32/41] enable skipping of errorneous collections in /collections (#2267) --- pygeoapi/api/__init__.py | 488 +----------------- pygeoapi/api/admin.py | 5 +- pygeoapi/api/collection.py | 475 +++++++++++++++++ pygeoapi/api/coverages.py | 3 +- pygeoapi/api/environmental_data_retrieval.py | 4 +- pygeoapi/api/itemtypes.py | 6 +- pygeoapi/api/maps.py | 5 +- pygeoapi/api/processes.py | 6 +- pygeoapi/api/stac.py | 5 +- pygeoapi/api/tiles.py | 7 +- pygeoapi/formats.py | 51 ++ pygeoapi/process/manager/mongodb_.py | 4 +- pygeoapi/process/manager/postgresql.py | 4 +- pygeoapi/process/manager/tinydb_.py | 4 +- tests/api/test_api.py | 38 +- tests/api/test_itemtypes.py | 6 +- tests/api/test_processes.py | 4 +- tests/api/test_stac.py | 4 +- tests/api/test_tiles.py | 4 +- ...ygeoapi-test-config-failing-collection.yml | 206 ++++++++ 20 files changed, 824 insertions(+), 505 deletions(-) create mode 100644 pygeoapi/api/collection.py create mode 100644 pygeoapi/formats.py create mode 100644 tests/pygeoapi-test-config-failing-collection.yml diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 55a1d4e1f..c98cac3c1 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -40,7 +40,7 @@ Returns content from plugins and sets responses. """ -from collections import ChainMap, OrderedDict +from collections import ChainMap from copy import deepcopy from datetime import datetime from functools import partial @@ -56,21 +56,19 @@ import pytz from pygeoapi import __version__, l10n -from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_supported_crs_list +from pygeoapi.api.collection import gen_collection, OGC_RELTYPES_BASE +from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_HTML, F_JSON, F_JSONLD from pygeoapi.linked_data import jsonldify, jsonldify_collection from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin from pygeoapi.process.manager.base import get_manager -from pygeoapi.provider import ( - filter_providers_by_type, get_provider_by_type, get_provider_default) -from pygeoapi.provider.base import ( - ProviderConnectionError, ProviderGenericError, ProviderTypeError) +from pygeoapi.provider import filter_providers_by_type, get_provider_by_type +from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError from pygeoapi.util import ( - TEMPLATESDIR, UrlPrefetcher, dategetter, - filter_dict_by_key_value, get_api_rules, get_base_url, get_typed_value, - render_j2_template, to_json, get_choice_from_headers, get_from_headers, - get_dataset_formatters + TEMPLATESDIR, UrlPrefetcher, filter_dict_by_key_value, get_api_rules, + get_base_url, get_typed_value, render_j2_template, to_json, + get_choice_from_headers, get_from_headers ) LOGGER = logging.getLogger(__name__) @@ -82,26 +80,6 @@ } CHARSET = ['utf-8'] -F_JSON = 'json' -F_COVERAGEJSON = 'json' -F_HTML = 'html' -F_JSONLD = 'jsonld' -F_GZIP = 'gzip' -F_PNG = 'png' -F_JPEG = 'jpeg' -F_MVT = 'mvt' -F_NETCDF = 'NetCDF' - -#: Formats allowed for ?f= requests (order matters for complex MIME types) -FORMAT_TYPES = OrderedDict(( - (F_HTML, 'text/html'), - (F_JSONLD, 'application/ld+json'), - (F_JSON, 'application/json'), - (F_PNG, 'image/png'), - (F_JPEG, 'image/jpeg'), - (F_MVT, 'application/vnd.mapbox-vector-tile'), - (F_NETCDF, 'application/x-netcdf'), -)) #: Locale used for system responses (e.g. exceptions) SYSTEM_LOCALE = l10n.Locale('en', 'US') @@ -115,8 +93,6 @@ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30' ] -OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' - def all_apis() -> dict: """ @@ -511,7 +487,7 @@ def get_response_headers(self, force_lang: l10n.Locale | None = None, if F_GZIP in FORMAT_TYPES: if force_encoding: headers['Content-Encoding'] = force_encoding - elif F_GZIP in get_from_headers(self._headers, 'accept-encoding'): + elif F_GZIP in get_from_headers(self._headers, 'accept-encoding'): # noqa headers['Content-Encoding'] = F_GZIP return headers @@ -950,9 +926,7 @@ def describe_collections(api: API, request: APIRequest, HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) if dataset is not None: - collections_dict = { - k: v for k, v in collections.items() if k == dataset - } + collections_dict = {dataset: api.config['resources'][dataset]} else: collections_dict = collections @@ -961,439 +935,21 @@ def describe_collections(api: API, request: APIRequest, if v.get('visibility', 'default') == 'hidden': LOGGER.debug(f'Skipping hidden layer: {k}') continue - collection_data = get_provider_default(v['providers']) - collection_data_type = collection_data['type'] - - collection_data_format = None - - if 'format' in collection_data: - collection_data_format = collection_data['format'] - - is_vector_tile = (collection_data_type == 'tile' and - collection_data_format['name'] not - in [F_PNG, F_JPEG]) - - collection = { - 'id': k, - 'title': l10n.translate(v['title'], request.locale), - 'description': l10n.translate(v['description'], request.locale), # noqa - 'keywords': l10n.translate(v['keywords'], request.locale), - 'links': [] - } - - extents = deepcopy(v['extents']) - - bbox = extents['spatial']['bbox'] - LOGGER.debug('Setting spatial extents from configuration') - # The output should be an array of bbox, so if the user only - # provided a single bbox, wrap it in a array. - if not isinstance(bbox[0], list): - bbox = [bbox] - collection['extent'] = { - 'spatial': { - 'bbox': bbox - } - } - if 'crs' in extents['spatial']: - collection['extent']['spatial']['crs'] = \ - extents['spatial']['crs'] - - t_ext = extents.get('temporal', {}) - if t_ext: - LOGGER.debug('Setting temporal extents from configuration') - begins = dategetter('begin', t_ext) - ends = dategetter('end', t_ext) - collection['extent']['temporal'] = { - 'interval': [[begins, ends]] - } - if 'trs' in t_ext: - collection['extent']['temporal']['trs'] = t_ext['trs'] - if 'resolution' in t_ext: - collection['extent']['temporal']['grid'] = { - 'resolution': t_ext['resolution'] - } - if 'default' in t_ext: - collection['extent']['temporal']['default'] = t_ext['default'] - - _ = extents.pop('spatial', None) - _ = extents.pop('temporal', None) - - for ek, ev in extents.items(): - LOGGER.debug(f'Adding extent {ek}') - collection['extent'][ek] = { - 'definition': ev['url'], - 'interval': [ev['range']] - } - if 'units' in ev: - collection['extent'][ek]['unit'] = ev['units'] - - if 'values' in ev: - collection['extent'][ek]['grid'] = { - 'cellsCount': len(ev['values']), - 'coordinates': ev['values'] - } - - LOGGER.debug('Processing configured collection links') - for link in l10n.translate(v.get('links', []), request.locale): - lnk = { - 'type': link['type'], - 'rel': link['rel'], - 'title': l10n.translate(link['title'], request.locale), - 'href': l10n.translate(link['href'], request.locale), - } - if 'hreflang' in link: - lnk['hreflang'] = l10n.translate( - link['hreflang'], request.locale) - content_length = link.get('length', 0) - - if lnk['rel'] == 'enclosure' and content_length == 0: - # Issue HEAD request for enclosure links without length - lnk_headers = api.prefetcher.get_headers(lnk['href']) - content_length = int(lnk_headers.get('content-length', 0)) - content_type = lnk_headers.get('content-type', lnk['type']) - if content_length == 0: - # Skip this (broken) link - LOGGER.debug(f"Enclosure {lnk['href']} is invalid") - continue - if content_type != lnk['type']: - # Update content type if different from specified - lnk['type'] = content_type - LOGGER.debug( - f"Fixed media type for enclosure {lnk['href']}") - - if content_length > 0: - lnk['length'] = content_length - - collection['links'].append(lnk) - - # TODO: provide translations - LOGGER.debug('Adding JSON and HTML link relations') - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': 'root', - 'title': l10n.translate('The landing page of this server as JSON', request.locale), # noqa - 'href': f"{api.base_url}?f={F_JSON}" - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'root', - 'title': l10n.translate('The landing page of this server as HTML', request.locale), # noqa - 'href': f"{api.base_url}?f={F_HTML}" - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': request.get_linkrel(F_JSON), - 'title': l10n.translate('This document as JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSONLD], - 'rel': request.get_linkrel(F_JSONLD), - 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_JSONLD}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': request.get_linkrel(F_HTML), - 'title': l10n.translate('This document as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}' - }) - - if collection_data_type == 'record': - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', - 'title': l10n.translate('Record catalogue as JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', - 'title': l10n.translate('Record catalogue as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}' - }) - - if collection_data_type in ['feature', 'coverage', 'record']: - collection['links'].append({ - 'type': 'application/schema+json', - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': l10n.translate('Schema of collection in JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/schema?f={F_JSON}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': l10n.translate('Schema of collection in HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/schema?f={F_HTML}' # noqa - }) - - if is_vector_tile or collection_data_type in ['feature', 'record']: - # TODO: translate - collection['itemType'] = collection_data_type - LOGGER.debug('Adding feature/record based links') - collection['links'].append({ - 'type': 'application/schema+json', - 'rel': f'{OGC_RELTYPES_BASE}/queryables', - 'title': l10n.translate('Queryables for this collection as JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_JSON}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/queryables', - 'title': l10n.translate('Queryables for this collection as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_HTML}' # noqa - }) - collection['links'].append({ - 'type': 'application/geo+json', - 'rel': 'items', - 'title': l10n.translate('Items as GeoJSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={F_JSON}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSONLD], - 'rel': 'items', - 'title': l10n.translate('Items as RDF (GeoJSON-LD)', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={F_JSONLD}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'items', - 'title': l10n.translate('Items as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={F_HTML}' # noqa - }) - - for key, value in get_dataset_formatters(v).items(): - collection['links'].append({ - 'type': value.mimetype, - 'rel': 'items', - 'title': l10n.translate(f'Items as {key}', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={value.f}' # noqa - }) - - # OAPIF Part 2 - list supported CRSs and StorageCRS - if collection_data_type in ['edr', 'feature']: - collection['crs'] = get_supported_crs_list(collection_data) - collection['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa - if 'storage_crs_coordinate_epoch' in collection_data: - collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa - - elif collection_data_type == 'coverage': - # TODO: translate - LOGGER.debug('Adding coverage based links') - collection['links'].append({ - 'type': 'application/prs.coverage+json', - 'rel': f'{OGC_RELTYPES_BASE}/coverage', - 'title': l10n.translate('Coverage data', request.locale), - 'href': f'{api.get_collections_url()}/{k}/coverage?f={F_JSON}' # noqa - }) - if collection_data_format is not None: - title_ = l10n.translate('Coverage data as', request.locale) # noqa - title_ = f"{title_} {collection_data_format['name']}" - collection['links'].append({ - 'type': collection_data_format['mimetype'], - 'rel': f'{OGC_RELTYPES_BASE}/coverage', - 'title': title_, - 'href': f"{api.get_collections_url()}/{k}/coverage?f={collection_data_format['name']}" # noqa - }) - if dataset is not None: - LOGGER.debug('Creating extended coverage metadata') - try: - provider_def = get_provider_by_type( - api.config['resources'][k]['providers'], - 'coverage') - p = load_plugin('provider', provider_def) - except ProviderConnectionError: - msg = 'connection error (check logs)' - return api.get_exception( - HTTPStatus.INTERNAL_SERVER_ERROR, - headers, request.format, - 'NoApplicableCode', msg) - except ProviderTypeError: - pass - else: - collection['extent']['spatial']['grid'] = [{ - 'cellsCount': p._coverage_properties['width'], - 'resolution': p._coverage_properties['resx'] - }, { - 'cellsCount': p._coverage_properties['height'], - 'resolution': p._coverage_properties['resy'] - }] - if 'time_range' in p._coverage_properties: - collection['extent']['temporal'] = { - 'interval': [p._coverage_properties['time_range']] - } - if 'restime' in p._coverage_properties: - collection['extent']['temporal']['grid'] = { - 'resolution': p._coverage_properties['restime'] # noqa - } - if 'uad' in p._coverage_properties: - collection['extent'].update(p._coverage_properties['uad']) # noqa try: - tile = get_provider_by_type(v['providers'], 'tile') - p = load_plugin('provider', tile) - except ProviderConnectionError: - msg = 'connection error (check logs)' - return api.get_exception( - HTTPStatus.INTERNAL_SERVER_ERROR, - headers, request.format, - 'NoApplicableCode', msg) - except ProviderTypeError: - tile = None - - if tile: - # TODO: translate - - LOGGER.debug('Adding tile links') - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', - 'title': l10n.translate('Tiles as JSON', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', - 'title': l10n.translate('Tiles as HTML', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' - }) - - try: - map_ = get_provider_by_type(v['providers'], 'map') - p = load_plugin('provider', map_) - except ProviderTypeError: - map_ = None - - if map_: - LOGGER.debug('Adding map links') - - map_mimetype = map_['format']['mimetype'] - map_format = map_['format']['name'] - - title_ = l10n.translate('Map as', request.locale) - title_ = f'{title_} {map_format}' - - collection['links'].append({ - 'type': map_mimetype, - 'rel': f'{OGC_RELTYPES_BASE}/map', - 'title': title_, - 'href': f'{api.get_collections_url()}/{k}/map?f={map_format}' - }) - - if p._fields: - schema_reltype = f'{OGC_RELTYPES_BASE}/schema', - schema_links = [s for s in collection['links'] if - schema_reltype in s] - - if not schema_links: - title_ = l10n.translate('Schema of collection in JSON', request.locale) # noqa - collection['links'].append({ - 'type': 'application/schema+json', - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': title_, - 'href': f'{api.get_collections_url()}/{k}/schema?f=json' # noqa - }) - title_ = l10n.translate('Schema of collection in HTML', request.locale) # noqa - collection['links'].append({ - 'type': 'text/html', - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': title_, - 'href': f'{api.get_collections_url()}/{k}/schema?f=html' # noqa - }) + fcm['collections'].append( + gen_collection(api, request, k, request.locale)) + except Exception as err: + LOGGER.warning(f'Error generating collection {k}: {err}') + if dataset is None: + LOGGER.debug('Skipping failed dataset') + else: + return api.get_exception( + HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, + 'NoApplicableCode', 'Error generating collection') - try: - edr = get_provider_by_type(v['providers'], 'edr') - p = load_plugin('provider', edr) - except ProviderConnectionError: - msg = 'connection error (check logs)' - return api.get_exception( - HTTPStatus.INTERNAL_SERVER_ERROR, headers, - request.format, 'NoApplicableCode', msg) - except ProviderTypeError: - edr = None - - if edr: - # TODO: translate - LOGGER.debug('Adding EDR links') - collection['data_queries'] = {} - parameters = p.get_fields() - if parameters: - collection['parameter_names'] = {} - for key, value in parameters.items(): - collection['parameter_names'][key] = { - 'id': key, - 'type': 'Parameter', - 'name': value['title'], - 'observedProperty': { - 'label': { - 'id': key, - 'en': value['title'] - }, - }, - 'unit': { - 'label': { - 'en': value['title'] - }, - 'symbol': { - 'value': value['x-ogc-unit'], - 'type': 'http://www.opengis.net/def/uom/UCUM/' # noqa - } - } - } - - collection['parameter_names'][key].update({ - 'description': value['description']} - if 'description' in value else {} - ) - - for qt in p.get_query_types(): - data_query = { - 'link': { - 'href': f'{api.get_collections_url()}/{k}/{qt}', - 'rel': 'data', - 'variables': { - 'query_type': qt - } - } - } - - if request.format is not None and request.format == 'json': - data_query['link']['type'] = 'application/vnd.cov+json' - - collection['data_queries'][qt] = data_query - - title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa - title1 = f'{qt} {title1}' - title2 = l10n.translate('query for this collection as HTML', request.locale) # noqa - title2 = f'{qt} {title2}' - - collection['links'].append({ - 'type': 'application/json', - 'rel': 'data', - 'title': title1, - 'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'data', - 'title': title2, - 'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_HTML}' - }) - - for key, value in get_dataset_formatters(v).items(): - title3 = f'{qt} query for this collection as {key}' - collection['links'].append({ - 'type': value.mimetype, - 'rel': 'data', - 'title': title3, - 'href': f'{api.get_collections_url()}/{k}/{qt}?f={value.f}' # noqa - }) - - if dataset is not None and k == dataset: - fcm = collection - break - - fcm['collections'].append(collection) + if dataset is not None: + fcm = fcm['collections'][0] if dataset is None: # TODO: translate diff --git a/pygeoapi/api/admin.py b/pygeoapi/api/admin.py index bf485515b..a971e1f25 100644 --- a/pygeoapi/api/admin.py +++ b/pygeoapi/api/admin.py @@ -3,7 +3,7 @@ # Authors: Tom Kralidis # Benjamin Webb # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2023 Benjamin Webb # # Permission is hereby granted, free of charge, to any person @@ -39,8 +39,9 @@ from jsonpatch import make_patch from jsonschema.exceptions import ValidationError -from pygeoapi.api import API, APIRequest, F_HTML +from pygeoapi.api import API, APIRequest from pygeoapi.config import get_config, validate_config +from pygeoapi.formats import F_HTML from pygeoapi.openapi import get_oas from pygeoapi.util import to_json, render_j2_template, yaml_dump diff --git a/pygeoapi/api/collection.py b/pygeoapi/api/collection.py new file mode 100644 index 000000000..2f3c7fea1 --- /dev/null +++ b/pygeoapi/api/collection.py @@ -0,0 +1,475 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# Francesco Bartoli +# Sander Schaminee +# John A Stevenson +# Colin Blackburn +# Ricardo Garcia Silva +# +# Copyright (c) 2026 Tom Kralidis +# Copyright (c) 2026 Francesco Bartoli +# Copyright (c) 2022 John A Stevenson and Colin Blackburn +# Copyright (c) 2023 Ricardo Garcia Silva +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from copy import deepcopy +import logging + +from pygeoapi import l10n +from pygeoapi.formats import (F_JSON, F_JSONLD, F_HTML, F_JPEG, + F_PNG, FORMAT_TYPES) +from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_supported_crs_list +from pygeoapi.plugin import load_plugin +from pygeoapi.provider import get_provider_by_type, get_provider_default +from pygeoapi.provider.base import ProviderConnectionError, ProviderTypeError +from pygeoapi.util import dategetter, get_dataset_formatters + +LOGGER = logging.getLogger(__name__) + +OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' + + +def gen_collection(api, request, dataset: str, + locale_: str) -> dict: + """ + Generate OGC API Collection description + + :param api: `APIRequest` object + :param dataset: `str` of dataset name + :param locale_: `str` of requested locale + + :returns: `dict` of OGC API Collection description + """ + + config = api.config['resources'][dataset] + + data = { + 'id': dataset, + 'links': [] + } + + collection_data = get_provider_default(config['providers']) + collection_data_type = collection_data['type'] + + collection_data_format = None + + if 'format' in collection_data: + collection_data_format = collection_data['format'] + + is_vector_tile = (collection_data_type == 'tile' and + collection_data_format['name'] not + in [F_PNG, F_JPEG]) + + data.update({ + 'title': l10n.translate(config['title'], locale_), + 'description': l10n.translate(config['description'], locale_), + 'keywords': l10n.translate(config['keywords'], locale_), + }) + + extents = deepcopy(config['extents']) + + bbox = extents['spatial']['bbox'] + LOGGER.debug('Setting spatial extents from configuration') + # The output should be an array of bbox, so if the user only + # provided a single bbox, wrap it in a array. + if not isinstance(bbox[0], list): + bbox = [bbox] + + data['extent'] = { + 'spatial': { + 'bbox': bbox + } + } + + if 'crs' in extents['spatial']: + data['extent']['spatial']['crs'] = extents['spatial']['crs'] + + t_ext = extents.get('temporal', {}) + if t_ext: + LOGGER.debug('Setting temporal extents from configuration') + begins = dategetter('begin', t_ext) + ends = dategetter('end', t_ext) + data['extent']['temporal'] = { + 'interval': [[begins, ends]] + } + if 'trs' in t_ext: + data['extent']['temporal']['trs'] = t_ext['trs'] + if 'resolution' in t_ext: + data['extent']['temporal']['grid'] = { + 'resolution': t_ext['resolution'] + } + if 'default' in t_ext: + data['extent']['temporal']['default'] = t_ext['default'] + + _ = extents.pop('spatial', None) + _ = extents.pop('temporal', None) + + for ek, ev in extents.items(): + LOGGER.debug(f'Adding extent {ek}') + data['extent'][ek] = { + 'definition': ev['url'], + 'interval': [ev['range']] + } + if 'units' in ev: + data['extent'][ek]['unit'] = ev['units'] + + if 'values' in ev: + data['extent'][ek]['grid'] = { + 'cellsCount': len(ev['values']), + 'coordinates': ev['values'] + } + + LOGGER.debug('Processing configured collection links') + for link in l10n.translate(config.get('links', []), locale_): + lnk = { + 'type': link['type'], + 'rel': link['rel'], + 'title': l10n.translate(link['title'], locale_), + 'href': l10n.translate(link['href'], locale_), + } + if 'hreflang' in link: + lnk['hreflang'] = l10n.translate( + link['hreflang'], locale_) + content_length = link.get('length', 0) + + if lnk['rel'] == 'enclosure' and content_length == 0: + # Issue HEAD request for enclosure links without length + lnk_headers = api.prefetcher.get_headers(lnk['href']) + content_length = int(lnk_headers.get('content-length', 0)) + content_type = lnk_headers.get('content-type', lnk['type']) + if content_length == 0: + # Skip this (broken) link + LOGGER.debug(f"Enclosure {lnk['href']} is invalid") + continue + if content_type != lnk['type']: + # Update content type if different from specified + lnk['type'] = content_type + LOGGER.debug( + f"Fixed media type for enclosure {lnk['href']}") + + if content_length > 0: + lnk['length'] = content_length + + data['links'].append(lnk) + + # TODO: provide translations + LOGGER.debug('Adding JSON and HTML link relations') + data['links'].extend([{ + 'type': FORMAT_TYPES[F_JSON], + 'rel': 'root', + 'title': l10n.translate('The landing page of this server as JSON', locale_), # noqa + 'href': f"{api.base_url}?f={F_JSON}" + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'root', + 'title': l10n.translate('The landing page of this server as HTML', locale_), # noqa + 'href': f"{api.base_url}?f={F_HTML}" + }, { + 'type': FORMAT_TYPES[F_JSON], + 'rel': request.get_linkrel(F_JSON), + 'title': l10n.translate('This document as JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': request.get_linkrel(F_JSONLD), + 'title': l10n.translate('This document as RDF (JSON-LD)', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_JSONLD}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': request.get_linkrel(F_HTML), + 'title': l10n.translate('This document as HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_HTML}' + }]) + + if collection_data_type == 'record': + data['links'].extend([{ + 'type': FORMAT_TYPES[F_JSON], + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', + 'title': l10n.translate('Record catalogue as JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', + 'title': l10n.translate('Record catalogue as HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_HTML}' + }]) + + if collection_data_type in ['feature', 'coverage', 'record']: + data['links'].extend([{ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': l10n.translate('Schema of collection in JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/schema?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': l10n.translate('Schema of collection in HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/schema?f={F_HTML}' + }]) + + if is_vector_tile or collection_data_type in ['feature', 'record']: + # TODO: translate + data['itemType'] = collection_data_type + LOGGER.debug('Adding feature/record based links') + data['links'].extend([{ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/queryables', + 'title': l10n.translate('Queryables for this collection as JSON', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/queryables?f={F_JSON}' # noqa + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/queryables', + 'title': l10n.translate('Queryables for this collection as HTML', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/queryables?f={F_HTML}' # noqa + }, { + 'type': 'application/geo+json', + 'rel': 'items', + 'title': l10n.translate('Items as GeoJSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/items?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': 'items', + 'title': l10n.translate('Items as RDF (GeoJSON-LD)', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/items?f={F_JSONLD}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'items', + 'title': l10n.translate('Items as HTML', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/items?f={F_HTML}' + }]) + + for key, value in get_dataset_formatters(config).items(): + data['links'].append({ + 'type': value.mimetype, + 'rel': 'items', + 'title': l10n.translate(f'Items as {key}', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/items?f={value.f}' # noqa + }) + + # OAPIF Part 2 - list supported CRSs and StorageCRS + if collection_data_type in ['edr', 'feature']: + data['crs'] = get_supported_crs_list(collection_data) + data['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa + if 'storage_crs_coordinate_epoch' in collection_data: + data['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa + + elif collection_data_type == 'coverage': + LOGGER.debug('Adding coverage based links') + data['links'].append({ + 'type': 'application/prs.coverage+json', + 'rel': f'{OGC_RELTYPES_BASE}/coverage', + 'title': l10n.translate('Coverage data', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/coverage?f={F_JSON}' # noqa + }) + if collection_data_format is not None: + title_ = l10n.translate('Coverage data as', locale_) + title_ = f"{title_} {collection_data_format['name']}" + data['links'].append({ + 'type': collection_data_format['mimetype'], + 'rel': f'{OGC_RELTYPES_BASE}/coverage', + 'title': title_, + 'href': f"{api.get_collections_url()}/{dataset}/coverage?f={collection_data_format['name']}" # noqa + }) + if dataset is not None: + LOGGER.debug('Creating extended coverage metadata') + try: + provider_def = get_provider_by_type( + api.config['resources'][dataset]['providers'], + 'coverage') + p = load_plugin('provider', provider_def) + except ProviderConnectionError: + raise + except ProviderTypeError: + pass + else: + data['extent']['spatial']['grid'] = [{ + 'cellsCount': p._coverage_properties['width'], + 'resolution': p._coverage_properties['resx'] + }, { + 'cellsCount': p._coverage_properties['height'], + 'resolution': p._coverage_properties['resy'] + }] + if 'time_range' in p._coverage_properties: + data['extent']['temporal'] = { + 'interval': [p._coverage_properties['time_range']] + } + if 'restime' in p._coverage_properties: + data['extent']['temporal']['grid'] = { + 'resolution': p._coverage_properties['restime'] + } + if 'uad' in p._coverage_properties: + data['extent'].update(p._coverage_properties['uad']) + + try: + tile = get_provider_by_type(config['providers'], 'tile') + p = load_plugin('provider', tile) + except ProviderConnectionError: + raise + except ProviderTypeError: + tile = None + + if tile: + LOGGER.debug('Adding tile links') + data['links'].extend([{ + 'type': FORMAT_TYPES[F_JSON], + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', + 'title': l10n.translate('Tiles as JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', + 'title': l10n.translate('Tiles as HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_HTML}' + }]) + + try: + map_ = get_provider_by_type(config['providers'], 'map') + p = load_plugin('provider', map_) + except ProviderTypeError: + map_ = None + + if map_: + LOGGER.debug('Adding map links') + + map_mimetype = map_['format']['mimetype'] + map_format = map_['format']['name'] + + title_ = l10n.translate('Map as', locale_) + title_ = f'{title_} {map_format}' + + data['links'].append({ + 'type': map_mimetype, + 'rel': f'{OGC_RELTYPES_BASE}/map', + 'title': title_, + 'href': f'{api.get_collections_url()}/{dataset}/map?f={map_format}' + }) + + if p._fields: + schema_reltype = f'{OGC_RELTYPES_BASE}/schema', + schema_links = [s for s in data['links'] if + schema_reltype in s] + + if not schema_links: + title_ = l10n.translate('Schema of collection in JSON', locale_) # noqa + data['links'].append({ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{dataset}/schema?f=json' # noqa + }) + title_ = l10n.translate('Schema of collection in HTML', locale_) # noqa + data['links'].append({ + 'type': 'text/html', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{dataset}/schema?f=html' # noqa + }) + + try: + edr = get_provider_by_type(config['providers'], 'edr') + p = load_plugin('provider', edr) + except ProviderConnectionError: + raise + except ProviderTypeError: + edr = None + + if edr: + # TODO: translate + LOGGER.debug('Adding EDR links') + data['data_queries'] = {} + parameters = p.get_fields() + if parameters: + data['parameter_names'] = {} + for key, value in parameters.items(): + data['parameter_names'][key] = { + 'id': key, + 'type': 'Parameter', + 'name': value['title'], + 'observedProperty': { + 'label': { + 'id': key, + 'en': value['title'] + }, + }, + 'unit': { + 'label': { + 'en': value['title'] + }, + 'symbol': { + 'value': value['x-ogc-unit'], + 'type': 'http://www.opengis.net/def/uom/UCUM/' + } + } + } + + data['parameter_names'][key].update({ + 'description': value['description']} + if 'description' in value else {} + ) + + for qt in p.get_query_types(): + data_query = { + 'link': { + 'href': f'{api.get_collections_url()}/{dataset}/{qt}', + 'rel': 'data', + 'variables': { + 'query_type': qt + } + } + } + + if request.format is not None and request.format == 'json': + data_query['link']['type'] = 'application/vnd.cov+json' + + data['data_queries'][qt] = data_query + + title1 = l10n.translate('query for this collection as JSON', locale_) # noqa + title1 = f'{qt} {title1}' + title2 = l10n.translate('query for this collection as HTML', locale_) # noqa + title2 = f'{qt} {title2}' + + data['links'].extend([{ + 'type': 'application/json', + 'rel': 'data', + 'title': title1, + 'href': f'{api.get_collections_url()}/{dataset}/{qt}?f={F_JSON}' # noqa + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'data', + 'title': title2, + 'href': f'{api.get_collections_url()}/{dataset}/{qt}?f={F_HTML}' # noqa + }]) + + for key, value in get_dataset_formatters(config).items(): + title3 = f'{qt} query for this collection as {key}' + data['links'].append({ + 'type': value.mimetype, + 'rel': 'data', + 'title': title3, + 'href': f'{api.get_collections_url()}/{dataset}/{qt}?f={value.f}' # noqa + }) + + return data diff --git a/pygeoapi/api/coverages.py b/pygeoapi/api/coverages.py index 327744a01..67755407d 100644 --- a/pygeoapi/api/coverages.py +++ b/pygeoapi/api/coverages.py @@ -43,6 +43,7 @@ from typing import Tuple from pygeoapi import l10n +from pygeoapi.formats import F_JSON from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError @@ -50,7 +51,7 @@ from pygeoapi.util import filter_dict_by_key_value, to_json from . import ( - APIRequest, API, F_JSON, SYSTEM_LOCALE, validate_bbox, validate_datetime, + APIRequest, API, SYSTEM_LOCALE, validate_bbox, validate_datetime, validate_subset ) diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 7e1ef1f51..6f2ed36bb 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -49,6 +49,7 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit +from pygeoapi.formats import F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.crs import (create_crs_transform_spec, set_content_crs_header) from pygeoapi.openapi import get_oas_30_parameters @@ -60,8 +61,7 @@ render_j2_template, to_json, filter_dict_by_key_value) -from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD, - validate_datetime, validate_bbox) +from . import APIRequest, API, validate_datetime, validate_bbox LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 2aa85a973..db0a6e6fb 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -54,6 +54,7 @@ create_crs_transform_spec, get_supported_crs_list, modify_pygeofilter, transform_bbox, set_content_crs_header) +from pygeoapi.formats import F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.openapi import get_oas_30_parameters @@ -66,10 +67,7 @@ from pygeoapi.util import (to_json, filter_dict_by_key_value, str2bool, render_j2_template, get_dataset_formatters) -from . import ( - APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, - validate_bbox, validate_datetime -) +from . import APIRequest, API, SYSTEM_LOCALE, validate_bbox, validate_datetime LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index bcdda4b7a..8712e4c94 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -44,6 +44,7 @@ from typing import Tuple from pygeoapi.crs import transform_bbox +from pygeoapi.formats import F_JSON, FORMAT_TYPES from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider import filter_providers_by_type, get_provider_by_type @@ -52,9 +53,7 @@ ) from pygeoapi.util import to_json, filter_dict_by_key_value -from . import ( - APIRequest, API, F_JSON, FORMAT_TYPES, validate_datetime, validate_subset -) +from . import APIRequest, API, validate_datetime, validate_subset LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index b51ff8531..f39cee69b 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -49,6 +49,7 @@ from typing import Tuple from pygeoapi import l10n +from pygeoapi.formats import FORMAT_TYPES, F_HTML, F_JSON, F_JSONLD from pygeoapi.api import evaluate_limit from pygeoapi.api.pubsub import publish_message from pygeoapi.process.base import ( @@ -61,9 +62,7 @@ json_serial, render_j2_template, JobStatus, RequestedProcessExecutionMode, to_json, DATETIME_FORMAT) -from . import ( - APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, -) +from . import APIRequest, API, SYSTEM_LOCALE LOGGER = logging.getLogger(__name__) @@ -136,6 +135,7 @@ def describe_processes(api: API, request: APIRequest, p2['jobControlOptions'].append('async-execute') p2['outputTransmission'] = ['value'] + p2['links'] = p2.get('links', []) jobs_url = f"{api.base_url}/jobs" diff --git a/pygeoapi/api/stac.py b/pygeoapi/api/stac.py index a9227da26..ebf86b6e8 100644 --- a/pygeoapi/api/stac.py +++ b/pygeoapi/api/stac.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -47,6 +47,7 @@ from shapely import from_geojson from pygeoapi import l10n +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_HTML from pygeoapi import api as ogc_api from pygeoapi.api import itemtypes as itemtypes_api from pygeoapi.plugin import load_plugin @@ -58,7 +59,7 @@ from pygeoapi.util import (filter_dict_by_key_value, get_current_datetime, render_j2_template, to_json) -from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML +from . import APIRequest, API LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/tiles.py b/pygeoapi/api/tiles.py index fb8a39dbb..afdde22b1 100644 --- a/pygeoapi/api/tiles.py +++ b/pygeoapi/api/tiles.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -43,6 +43,7 @@ from typing import Tuple from pygeoapi import l10n +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_HTML, F_JSONLD from pygeoapi.plugin import load_plugin from pygeoapi.models.provider.base import (TilesMetadataFormat, TileMatrixSetEnum) @@ -54,9 +55,7 @@ from pygeoapi.util import to_json, filter_dict_by_key_value, render_j2_template -from . import ( - APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML, SYSTEM_LOCALE, F_JSONLD -) +from . import APIRequest, API, SYSTEM_LOCALE LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/formats.py b/pygeoapi/formats.py new file mode 100644 index 000000000..3ad1f481e --- /dev/null +++ b/pygeoapi/formats.py @@ -0,0 +1,51 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from collections import OrderedDict + +F_JSON = 'json' +F_COVERAGEJSON = 'json' +F_HTML = 'html' +F_JSONLD = 'jsonld' +F_GZIP = 'gzip' +F_PNG = 'png' +F_JPEG = 'jpeg' +F_MVT = 'mvt' +F_NETCDF = 'NetCDF' + +#: Formats allowed for ?f= requests (order matters for complex MIME types) +FORMAT_TYPES = OrderedDict(( + (F_HTML, 'text/html'), + (F_JSONLD, 'application/ld+json'), + (F_JSON, 'application/json'), + (F_PNG, 'image/png'), + (F_JPEG, 'image/jpeg'), + (F_MVT, 'application/vnd.mapbox-vector-tile'), + (F_NETCDF, 'application/x-netcdf'), +)) diff --git a/pygeoapi/process/manager/mongodb_.py b/pygeoapi/process/manager/mongodb_.py index 44bce6dbe..06e6d909a 100644 --- a/pygeoapi/process/manager/mongodb_.py +++ b/pygeoapi/process/manager/mongodb_.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Alexander Pilz +# Tom Kralidis # # Copyright (c) 2023 Alexander Pilz +# Copyright (c) 2026 Alexander Pilz # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -32,11 +34,11 @@ from pymongo import MongoClient -from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, ) +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.manager.base import BaseManager LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/process/manager/postgresql.py b/pygeoapi/process/manager/postgresql.py index 82ed5eb91..05dc408ee 100644 --- a/pygeoapi/process/manager/postgresql.py +++ b/pygeoapi/process/manager/postgresql.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Francesco Martinelli +# Tom Kralidis # # Copyright (c) 2024 Francesco Martinelli +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -48,12 +50,12 @@ from sqlalchemy import insert, update, delete from sqlalchemy.orm import Session -from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, ProcessorGenericError ) +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.manager.base import BaseManager from pygeoapi.provider.sql import ( get_engine, get_table_model, store_db_parameters diff --git a/pygeoapi/process/manager/tinydb_.py b/pygeoapi/process/manager/tinydb_.py index b04d29a49..c15e9d36a 100644 --- a/pygeoapi/process/manager/tinydb_.py +++ b/pygeoapi/process/manager/tinydb_.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -37,7 +37,7 @@ import tinydb from filelock import FileLock -from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 763fdc77d..bee15c1b5 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -41,11 +41,11 @@ import pytest from pygeoapi.api import ( - API, APIRequest, CONFORMANCE_CLASSES, FORMAT_TYPES, F_HTML, F_JSON, - F_JSONLD, F_GZIP, __version__, validate_bbox, validate_datetime, - evaluate_limit, validate_subset, landing_page, openapi_, conformance, - describe_collections, get_collection_schema, -) + API, APIRequest, CONFORMANCE_CLASSES, __version__, validate_bbox, + validate_datetime, evaluate_limit, validate_subset, landing_page, openapi_, + conformance, describe_collections, get_collection_schema) + +from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_JSON, F_JSONLD, F_HTML from pygeoapi.util import yaml_load, get_api_rules, get_base_url from tests.util import (get_test_file_path, mock_api_request, mock_flask, @@ -79,6 +79,13 @@ def config_hidden_resources(): return yaml_load(fh) +@pytest.fixture() +def config_failing_collection(): + filename = 'pygeoapi-test-config-failing-collection.yml' + with open(get_test_file_path(filename)) as fh: + return yaml_load(fh) + + @pytest.fixture() def enclosure_api(config_enclosure, openapi): """ Returns an API instance with a collection with enclosure links. """ @@ -98,6 +105,11 @@ def api_hidden_resources(config_hidden_resources, openapi): return API(config_hidden_resources, openapi) +@pytest.fixture() +def api_failing_collection(config_failing_collection, openapi): + return API(config_failing_collection, openapi) + + def test_apirequest(api_): # Test without (valid) locales with pytest.raises(ValueError): @@ -729,6 +741,22 @@ def test_describe_collections_hidden_resources( assert len(collections['collections']) == 1 +def test_describe_collections_failing_collection( + config_failing_collection, api_failing_collection): + req = mock_api_request({}) + rsp_headers, code, response = describe_collections(api_failing_collection, req) # noqa + assert code == HTTPStatus.OK + + assert len(config_failing_collection['resources']) == 3 + + collections = json.loads(response) + assert len(collections['collections']) == 2 + + req = mock_api_request({}) + rsp_headers, code, response = describe_collections(api_failing_collection, req, 'cmip5') # noqa + assert code == HTTPStatus.INTERNAL_SERVER_ERROR + + def test_describe_collections_json_ld(config, api_): req = mock_api_request({'f': 'jsonld'}) rsp_headers, code, response = describe_collections(api_, req, 'obs') diff --git a/tests/api/test_itemtypes.py b/tests/api/test_itemtypes.py index dd3fb9431..e5fceaefc 100644 --- a/tests/api/test_itemtypes.py +++ b/tests/api/test_itemtypes.py @@ -5,7 +5,7 @@ # Colin Blackburn # Francesco Bartoli # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2025 Francesco Bartoli # @@ -42,12 +42,12 @@ import pyproj from shapely.geometry import Point -from pygeoapi.api import (API, FORMAT_TYPES, F_GZIP, F_HTML, F_JSONLD, - apply_gzip) +from pygeoapi.api import API, apply_gzip from pygeoapi.api.itemtypes import ( get_collection_queryables, get_collection_item, get_collection_items, manage_collection_item) from pygeoapi.crs import get_crs +from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_HTML, F_JSONLD from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index a4bd3794f..e69ecb029 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # # Permission is hereby granted, free of charge, to any person @@ -37,10 +37,10 @@ import time from unittest import mock -from pygeoapi.api import FORMAT_TYPES, F_HTML, F_JSON from pygeoapi.api.processes import ( describe_processes, execute_process, delete_job, get_job_result, get_jobs ) +from pygeoapi.formats import FORMAT_TYPES, F_HTML, F_JSON from tests.util import mock_api_request diff --git a/tests/api/test_stac.py b/tests/api/test_stac.py index dea4de7bb..1da63919b 100644 --- a/tests/api/test_stac.py +++ b/tests/api/test_stac.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -31,8 +31,8 @@ import pytest -from pygeoapi.api import FORMAT_TYPES, F_JSON from pygeoapi.api.stac import search, landing_page +from pygeoapi.formats import FORMAT_TYPES, F_JSON from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request diff --git a/tests/api/test_tiles.py b/tests/api/test_tiles.py index d804f6a21..c463abf10 100644 --- a/tests/api/test_tiles.py +++ b/tests/api/test_tiles.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2025 Joana Simoes # @@ -37,12 +37,12 @@ from http import HTTPStatus import pytest -from pygeoapi.api import FORMAT_TYPES, F_HTML from pygeoapi.api.tiles import ( get_collection_tiles, tilematrixset, tilematrixsets, get_collection_tiles_metadata, get_collection_tiles_data ) +from pygeoapi.formats import FORMAT_TYPES, F_HTML from pygeoapi.models.provider.base import TileMatrixSetEnum from tests.util import mock_api_request diff --git a/tests/pygeoapi-test-config-failing-collection.yml b/tests/pygeoapi-test-config-failing-collection.yml new file mode 100644 index 000000000..8baddbd90 --- /dev/null +++ b/tests/pygeoapi-test-config-failing-collection.yml @@ -0,0 +1,206 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + obs: + type: collection + title: + en: Observations + fr: Observations + description: + en: My cool observations + fr: Mes belles observations + keywords: + - observations + - monitoring + links: + - type: text/csv + rel: canonical + title: data + href: https://github.com/mapserver/mapserver/blob/branch-7-0/msautotest/wxs/data/obs.csv + hreflang: en-US + - type: text/csv + rel: alternate + title: data + href: https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv + hreflang: en-US + 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] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: 2000-10-30T18:24:39Z + end: 2007-10-30T08:57:29Z + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + providers: + - type: feature + name: CSV + data: tests/data/obs.csv + id_field: id + geometry: + x_field: long + y_field: lat + + cmip5: + type: collection + title: CMIP5 sample + description: CMIP5 sample + keywords: + - cmip5 + - climate + extents: + spatial: + bbox: [-150,40,-45,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://open.canada.ca/data/en/dataset/eddd6eaf-34d7-4452-a994-3d928115a68b + hreflang: en-CA + providers: + - type: coverage + name: xarray + data: tests/data/CMIP5_rcp8.5_annual_abs_latlon1x1_PCP_pctl25_P1Y.nc404 + x_field: lon + y_field: lat + time_field: time + format: + name: NetCDF + mimetype: application/x-netcdf + + objects: + type: collection + title: GeoJSON objects + description: GeoJSON geometry types for GeoSparql and Schema Geometry conversion. + keywords: + - shapes + links: + - type: text/html + rel: canonical + title: data source + href: https://en.wikipedia.org/wiki/GeoJSON + hreflang: en-US + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: null + end: null # or empty (either means open ended) + providers: + - type: feature + name: GeoJSON + data: tests/data/items.geojson + id_field: fid + uri_field: uri From ff8465c91ce749f7fe5a4ad7940fe9c77925975b Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 5 Mar 2026 10:08:44 -0500 Subject: [PATCH 33/41] OAProc: derive process execution mode OpenAPI prefer option from process metadata (#2272) --- pygeoapi/api/processes.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index f39cee69b..9b506d0d3 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -760,7 +760,7 @@ def get_oas_30(cfg: dict, locale: str 'description': 'Indicates client preferences, including whether the client is capable of asynchronous processing.', # noqa 'schema': { 'type': 'string', - 'enum': ['respond-async'] + 'enum': [] } }], 'responses': { @@ -784,6 +784,12 @@ def get_oas_30(cfg: dict, locale: str } } + jco = p.metadata.get('jobControlOptions', ['sync-execute']) + if 'sync-execute' in jco: + paths[f'{process_name_path}/execution']['post']['parameters'][0]['schema']['enum'].append('respond-sync') # noqa + if 'async-execute' in jco: + paths[f'{process_name_path}/execution']['post']['parameters'][0]['schema']['enum'].append('respond-async') # noqa + try: first_key = list(p.metadata['outputs'])[0] p_output = p.metadata['outputs'][first_key] From 523c4bd6e4eadaad00806c098433418b2d721175 Mon Sep 17 00:00:00 2001 From: Angelos Tzotsos Date: Fri, 6 Mar 2026 14:07:06 +0200 Subject: [PATCH 34/41] Use the latest Ubuntu Noble base image (#2275) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d8947cbea..833f7a3ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,7 @@ # # ================================================================= -FROM ubuntu:noble-20260113 +FROM ubuntu:noble LABEL maintainer="Just van den Broecke " From 7ff27c13535a78c034a9569d92361074f02ed2cf Mon Sep 17 00:00:00 2001 From: Angelos Tzotsos Date: Fri, 6 Mar 2026 14:48:26 +0200 Subject: [PATCH 35/41] update release version --- docs/source/conf.py | 2 +- pygeoapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index a3f0ca7ed..1d4437193 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -112,7 +112,7 @@ def __getattr__(cls, name): # built documents. # # The short X.Y version. -version = '0.23.dev0' +version = '0.23.0' # The full version, including alpha/beta/rc tags. release = version diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index 30f235f00..96899ed1a 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -30,7 +30,7 @@ # # ================================================================= -__version__ = '0.23.dev0' +__version__ = '0.23.0' import click try: From 3764b1a643e22ddcdac32e23ef4af110394223c4 Mon Sep 17 00:00:00 2001 From: Angelos Tzotsos Date: Fri, 6 Mar 2026 15:20:21 +0200 Subject: [PATCH 36/41] back to dev --- docs/source/conf.py | 2 +- pygeoapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1d4437193..1b96113a2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -112,7 +112,7 @@ def __getattr__(cls, name): # built documents. # # The short X.Y version. -version = '0.23.0' +version = '0.24.dev0' # The full version, including alpha/beta/rc tags. release = version diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index 96899ed1a..e906bc2c3 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -30,7 +30,7 @@ # # ================================================================= -__version__ = '0.23.0' +__version__ = '0.24.dev0' import click try: From 49ed4cc46e12d255a276b05342f49d5bc0b9723c Mon Sep 17 00:00:00 2001 From: Jeff McKenna Date: Fri, 6 Mar 2026 15:34:49 -0400 Subject: [PATCH 37/41] update Security Policy (#2277) * update security policy * update security policy --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ec9a04f14..87520abc4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,5 +13,5 @@ The pygeoapi Project Steering Committee (PSC) will release patches for security | Version | Supported | | ------- | ------------------ | -| 0.10.x | :white_check_mark: | -| < 0.10 | :x: | +| 0.2x | :white_check_mark: | +| < 0.20 | :x: | From ed2b416d747934bef02596de752a2f9dda7da34f Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Fri, 6 Mar 2026 15:57:53 -0500 Subject: [PATCH 38/41] fix openapi generate example in docs (#2276) (#2278) --- docs/source/administration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/administration.rst b/docs/source/administration.rst index 9fe541f3a..c947365b3 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -32,7 +32,7 @@ To generate the OpenAPI document as JSON, run: .. code-block:: bash - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-openapi.yml + pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-openapi.json .. note:: Generate as YAML or JSON? If your OpenAPI YAML definition is slow to render as JSON, From 8f688a79f89fcf129a6f1cd389a40c87ab90ebad Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Fri, 6 Mar 2026 16:00:21 -0500 Subject: [PATCH 39/41] OAProc: derive jobControlOptions and outputTransmission from plugin (#2257) (#2274) --- docs/source/plugins.rst | 1 + pygeoapi/api/processes.py | 10 +- tests/api/test_processes.py | 39 ++++++- .../pygeoapi-test-config-process-metadata.yml | 106 ++++++++++++++++++ 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 tests/pygeoapi-test-config-process-metadata.yml diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 4d7e52e59..4b9a86922 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -410,6 +410,7 @@ Below is a sample process definition as a Python dictionary: 'it back as output. Intended to demonstrate a simple ' 'process with a single literal input.', 'jobControlOptions': ['sync-execute', 'async-execute'], # whether the process can be executed in sync or async mode + 'outputTransmission': ['value', 'reference'], # whether the process can return inline data or URL references 'keywords': ['hello world', 'example', 'echo'], # keywords associated with the process 'links': [{ # a list of 1..n # link objects relevant to the process 'type': 'text/html', diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 9b506d0d3..a3167cb7e 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -130,11 +130,15 @@ def describe_processes(api: API, request: APIRequest, p2.pop('outputs') p2.pop('example', None) - p2['jobControlOptions'] = ['sync-execute'] - if api.manager.is_async: + jco = p.metadata.get('jobControlOptions', ['sync-execute']) + p2['jobControlOptions'] = jco + + if api.manager.is_async and 'async-execute' not in jco: + LOGGER.debug('Adding async capability') p2['jobControlOptions'].append('async-execute') - p2['outputTransmission'] = ['value'] + p2['outputTransmission'] = p.metadata.get( + 'outputTransmission', ['value']) p2['links'] = p2.get('links', []) diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index e69ecb029..9d837747c 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -37,12 +37,28 @@ import time from unittest import mock +import pytest + +from pygeoapi.api import API from pygeoapi.api.processes import ( describe_processes, execute_process, delete_job, get_job_result, get_jobs ) from pygeoapi.formats import FORMAT_TYPES, F_HTML, F_JSON +from pygeoapi.util import yaml_load + +from tests.util import get_test_file_path, mock_api_request + -from tests.util import mock_api_request +@pytest.fixture() +def config_process_metadata() -> dict: + """ Returns a pygeoapi configuration with process metadata.""" + with open(get_test_file_path('pygeoapi-test-config-process-metadata.yml')) as fh: # noqa + return yaml_load(fh) + + +@pytest.fixture() +def api_process_metadata(config_process_metadata, openapi): + return API(config_process_metadata, openapi) def test_describe_processes(config, api_): @@ -143,8 +159,8 @@ def test_describe_processes(config, api_): # Test describe doesn't crash if example is missing req = mock_api_request() - processor = api_.manager.get_processor("hello-world") - example = processor.metadata.pop("example") + processor = api_.manager.get_processor('hello-world') + example = processor.metadata.pop('example') rsp_headers, code, response = describe_processes(api_, req) processor.metadata['example'] = example data = json.loads(response) @@ -152,6 +168,23 @@ def test_describe_processes(config, api_): assert len(data['processes']) == 2 +def test_describe_processes_metadata(config_process_metadata, + api_process_metadata): + + req = mock_api_request({'limit': 1}) + # Test for description of single processes + rsp_headers, code, response = describe_processes( + api_process_metadata, req, 'echo') + data = json.loads(response) + assert code == HTTPStatus.OK + assert len(data['jobControlOptions']) == 2 + assert 'sync-execute' in data['jobControlOptions'] + assert 'async-execute' in data['jobControlOptions'] + assert len(data['outputTransmission']) == 2 + assert 'value' in data['outputTransmission'] + assert 'reference' in data['outputTransmission'] + + def test_execute_process(config, api_): req_body_0 = { 'inputs': { diff --git a/tests/pygeoapi-test-config-process-metadata.yml b/tests/pygeoapi-test-config-process-metadata.yml new file mode 100644 index 000000000..1d8ddb8fd --- /dev/null +++ b/tests/pygeoapi-test-config-process-metadata.yml @@ -0,0 +1,106 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + echo: + type: process + processor: + name: Echo From 16736b22ebb7dc1da01a68391ef16f4d6f404654 Mon Sep 17 00:00:00 2001 From: Colton Loftus <70598503+C-Loftus@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:50:36 -0400 Subject: [PATCH 40/41] Geoparquet 1.1 spec compliance update (#2271) * working with geoparquet * tests * flake8 * single quotes * tweak single quptes * tweak single quotes * fix typo * revert log message * single quotes * remove dupe log * fix flake8 * fix failing test * response to feedback --- pygeoapi/provider/parquet.py | 263 ++++++++--- .../data-polygon-encoding_wkb_no_bbox.parquet | Bin 0 -> 1861 bytes .../geoparquet1.1/nyc_subset_overture.parquet | Bin 0 -> 145381 bytes tests/data/{ => parquet/naive}/random.parquet | Bin .../{ => parquet/naive}/random_nocrs.parquet | Bin .../{ => parquet/naive}/random_nogeom.parquet | Bin tests/provider/test_filesystem_provider.py | 2 +- tests/provider/test_parquet_provider.py | 430 +++++++++++------- 8 files changed, 460 insertions(+), 235 deletions(-) create mode 100644 tests/data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet create mode 100644 tests/data/parquet/geoparquet1.1/nyc_subset_overture.parquet rename tests/data/{ => parquet/naive}/random.parquet (100%) rename tests/data/{ => parquet/naive}/random_nocrs.parquet (100%) rename tests/data/{ => parquet/naive}/random_nogeom.parquet (100%) diff --git a/pygeoapi/provider/parquet.py b/pygeoapi/provider/parquet.py index 0f4ab3de1..8d69e9940 100644 --- a/pygeoapi/provider/parquet.py +++ b/pygeoapi/provider/parquet.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Leo Ghignone +# Colton Loftus # -# Copyright (c) 2024 Leo Ghignone +# Copyright (c) 2026 Leo Ghignone +# Copyright (c) 2026 Colton Loftus # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -36,6 +38,7 @@ import pyarrow import pyarrow.compute as pc import pyarrow.dataset +import pyarrow.types as pat import s3fs from pygeoapi.crs import crs_transform @@ -60,7 +63,41 @@ def arrow_to_pandas_type(arrow_type): return pd_type +def has_geoparquet_bbox_column( + pyarrow_geo_metadata: dict, primary_geometry_column_name: str +) -> bool: + """ + Check if the metadata on the parquet dataset + indicates there is a geoparquet bbox column + + :param pyarrow_geo_metadata: dict serialized version of the 'geo' + key within the pyarrow metadata json + :param primary_geometry_column_name: name of the primary geometry column + where the geometry is stored as specified in the 'geo' metadata + + :returns: bool whether or not the dataset has a geoparquet bbox column + """ + primary_column = pyarrow_geo_metadata.get('primary_column') + if primary_column is None: + return False + + columns = pyarrow_geo_metadata.get('columns') + if columns is None: + return False + + geometry_column_metadata = columns.get(primary_geometry_column_name) + if geometry_column_metadata is None: + return False + + geometry_covering = geometry_column_metadata.get('covering') + if geometry_covering is None: + return False + + return geometry_covering.get('bbox') is not None + + class ParquetProvider(BaseProvider): + def __init__(self, provider_def): """ Initialize object @@ -85,48 +122,107 @@ def __init__(self, provider_def): # Source url is required self.source = self.data.get('source') if not self.source: - msg = "Need explicit 'source' attr " \ - "in data field of provider config" + msg = 'Need explicit "source" attr in data' \ + ' field of provider config' LOGGER.error(msg) - raise Exception(msg) + raise ProviderGenericError(msg) # Manage AWS S3 sources if self.source.startswith('s3'): self.source = self.source.split('://', 1)[1] self.fs = s3fs.S3FileSystem(default_cache_type='none') else: + # If none, pyarrow will attempt to auto-detect self.fs = None # Build pyarrow dataset pointing to the data self.ds = pyarrow.dataset.dataset(self.source, filesystem=self.fs) + if not self.id_field: + LOGGER.info( + 'No "id_field" specified in parquet provider config' + ' will use pandas index as the identifier' + ) + else: + id_type = self.ds.schema.field(self.id_field).type + if ( + pat.is_integer(id_type) + or pat.is_decimal(id_type) + or pat.is_float_value(id_type) + ): + LOGGER.warning( + f'id_field is of type {id_type},' + ' and not numeric; this is harder to query and' + ' may cause slow full scans' + ) + LOGGER.debug('Grabbing field information') self.get_fields() # Must be set to visualise queryables - # Column names for bounding box data. - if None in [self.x_field, self.y_field]: + # Get the CRS of the data + if b'geo' in self.ds.schema.metadata: + geo_metadata = json.loads(self.ds.schema.metadata[b'geo']) + + geom_column = geo_metadata['primary_column'] + + if geom_column: + self.has_geometry = True + + # if the CRS is not set default to EPSG:4326, per geoparquet spec + self.crs = geo_metadata['columns'][geom_column].get('crs') \ + or 'OGC:CRS84' + + # self.bbox_filterable indicates whether or not + # we can resolve a bbox request + # against the data, either by using an explicit + # bbox column or by using x_field and y_field + # columns + self.bbox_filterable = \ + has_geoparquet_bbox_column(geo_metadata, geom_column) + if self.bbox_filterable: + # Whether or not the data has the geoparquet + # standardized bbox column + self.has_bbox_column = True + # if there is a bbox column we + # don't need to parse the x_fields and y_fields + # and can just return early + return + else: + self.has_bbox_column = False + else: self.has_geometry = False + self.has_bbox_column = False + + for field_name, field_value in [ + ('x_field', self.x_field), + ('y_field', self.y_field) + ]: + if not field_value: + LOGGER.warning( + f'No geometry for {self.source};' + f'missing {field_name} in parquet provider config' + ) + self.bbox_filterable = False + self.has_bbox_column = False + return + + # If there is not a geoparquet bbox column, + # then we fall back to reading fields for minx, maxx, miny, maxy + # as direct column names; these can be set and use regardless of + # whether or not there is 'geo' metadata + if isinstance(self.x_field, str): + self.minx = self.x_field + self.maxx = self.x_field else: - self.has_geometry = True - if isinstance(self.x_field, str): - self.minx = self.x_field - self.maxx = self.x_field - else: - self.minx, self.maxx = self.x_field + self.minx, self.maxx = self.x_field - if isinstance(self.y_field, str): - self.miny = self.y_field - self.maxy = self.y_field - else: - self.miny, self.maxy = self.y_field - self.bb = [self.minx, self.miny, self.maxx, self.maxy] + if isinstance(self.y_field, str): + self.miny = self.y_field + self.maxy = self.y_field + else: + self.miny, self.maxy = self.y_field - # Get the CRS of the data - geo_metadata = json.loads(self.ds.schema.metadata[b'geo']) - geom_column = geo_metadata['primary_column'] - # if the CRS is not set default to EPSG:4326, per geoparquet spec - self.crs = (geo_metadata['columns'][geom_column].get('crs') - or 'OGC:CRS84') + self.bbox_filterable = True def _read_parquet(self, return_scanner=False, **kwargs): """ @@ -134,7 +230,10 @@ def _read_parquet(self, return_scanner=False, **kwargs): :returns: generator of RecordBatch with the queried values """ - scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, **kwargs) + scanner = self.ds.scanner( + use_threads=True, + **kwargs + ) batches = scanner.to_batches() if return_scanner: return batches, scanner @@ -149,12 +248,19 @@ def get_fields(self): """ if not self._fields: - - for field_name, field_type in zip(self.ds.schema.names, - self.ds.schema.types): + for field_name, field_type in zip( + self.ds.schema.names, self.ds.schema.types + ): # Geometry is managed as a special case by pygeoapi if field_name == 'geometry': continue + # if we find the geoparquet bbox column and the + # type is a struct of any type, either double or + # float, then we skip it since it isn't + # meant to be a queryable field, rather just metadata + if field_name == 'bbox' and 'struct' in str(field_type): + self.bbox_filterable = True + continue field_type = str(field_type) converted_type = None @@ -213,28 +319,44 @@ def query( :returns: dict of 0..n GeoJSON features """ - result = None try: - filter = pc.scalar(True) + filter_ = pc.scalar(True) + if bbox: - if self.has_geometry is False: - msg = ( - 'Dataset does not have a geometry field, ' - 'querying by bbox is not supported.' + if not self.has_geometry: + raise ProviderQueryError( + ( + 'Dataset does not have a geometry field, ' + 'querying by bbox is not supported.' + ) + ) + + if not self.bbox_filterable: + raise ProviderQueryError( + ( + 'Dataset does not have a proper bbox metadata, ' + 'querying by bbox is not supported.' + ) ) - raise ProviderQueryError(msg) - LOGGER.debug('processing bbox parameter') - if any(b is None for b in bbox): - msg = 'Dataset does not support bbox filtering' - raise ProviderQueryError(msg) minx, miny, maxx, maxy = [float(b) for b in bbox] - filter = ( - (pc.field(self.minx) > pc.scalar(minx)) - & (pc.field(self.miny) > pc.scalar(miny)) - & (pc.field(self.maxx) < pc.scalar(maxx)) - & (pc.field(self.maxy) < pc.scalar(maxy)) - ) + + if self.has_bbox_column: + # GeoParquet bbox column is a struct + # with xmin, ymin, xmax, ymax + filter_ = filter_ & ( + (pc.field('bbox', 'xmin') >= pc.scalar(minx)) + & (pc.field('bbox', 'ymin') >= pc.scalar(miny)) + & (pc.field('bbox', 'xmax') <= pc.scalar(maxx)) + & (pc.field('bbox', 'ymax') <= pc.scalar(maxy)) + ) + else: + filter_ = ( + (pc.field(self.minx) >= pc.scalar(minx)) + & (pc.field(self.miny) >= pc.scalar(miny)) + & (pc.field(self.maxx) <= pc.scalar(maxx)) + & (pc.field(self.maxy) <= pc.scalar(maxy)) + ) if datetime_ is not None: if self.time_field is None: @@ -248,13 +370,13 @@ def query( begin, end = datetime_.split('/') if begin != '..': begin = isoparse(begin) - filter = filter & (timefield >= begin) + filter_ = filter_ & (timefield >= begin) if end != '..': end = isoparse(end) - filter = filter & (timefield <= end) + filter_ = filter_ & (timefield <= end) else: target_time = isoparse(datetime_) - filter = filter & (timefield == target_time) + filter_ = filter_ & (timefield == target_time) if properties: LOGGER.debug('processing properties') @@ -263,7 +385,7 @@ def query( pd_type = arrow_to_pandas_type(field.type) expr = pc.field(name) == pc.scalar(pd_type(value)) - filter = filter & expr + filter_ = filter_ & expr if len(select_properties) == 0: select_properties = self.ds.schema.names @@ -279,11 +401,11 @@ def query( # Make response based on resulttype specified if resulttype == 'hits': LOGGER.debug('hits only specified') - result = self._response_feature_hits(filter) + return self._response_feature_hits(filter_) elif resulttype == 'results': LOGGER.debug('results specified') - result = self._response_feature_collection( - filter, offset, limit, columns=select_properties + return self._response_feature_collection( + filter_, offset, limit, columns=select_properties ) else: LOGGER.error(f'Invalid resulttype: {resulttype}') @@ -298,8 +420,6 @@ def query( LOGGER.error(err) raise ProviderGenericError(err) - return result - @crs_transform def get(self, identifier, **kwargs): """ @@ -309,22 +429,22 @@ def get(self, identifier, **kwargs): :returns: a single feature """ - result = None try: LOGGER.debug(f'Fetching identifier {identifier}') id_type = arrow_to_pandas_type( - self.ds.schema.field(self.id_field).type) + self.ds.schema.field(self.id_field).type + ) batches = self._read_parquet( filter=( - pc.field(self.id_field) == pc.scalar(id_type(identifier)) - ) + pc.field(self.id_field) == pc.scalar(id_type(identifier) + )) ) for batch in batches: if batch.num_rows > 0: - assert ( - batch.num_rows == 1 - ), f'Multiple items found with ID {identifier}' + assert batch.num_rows == 1, ( + f'Multiple items found with ID {identifier}' + ) row = batch.to_pandas() break else: @@ -335,10 +455,14 @@ def get(self, identifier, **kwargs): else: geom = [None] gdf = gpd.GeoDataFrame(row, geometry=geom) + # If there is an id field, set it as index + # instead of the default numeric index + if self.id_field in gdf.columns: + gdf = gdf.set_index(self.id_field, drop=False) LOGGER.debug('results computed') # Grab the collection from geopandas geo_interface - result = gdf.__geo_interface__['features'][0] + return gdf.__geo_interface__['features'][0] except RuntimeError as err: LOGGER.error(err) @@ -353,13 +477,11 @@ def get(self, identifier, **kwargs): LOGGER.error(err) raise ProviderGenericError(err) - return result - def __repr__(self): return f' {self.data}' - def _response_feature_collection(self, filter, offset, limit, - columns=None): + def _response_feature_collection(self, filter, offset, + limit, columns=None): """ Assembles output from query as GeoJSON FeatureCollection structure. @@ -426,6 +548,10 @@ def _response_feature_collection(self, filter, offset, limit, geom = gpd.GeoSeries.from_wkb(rp['geometry'], crs=self.crs) gdf = gpd.GeoDataFrame(rp, geometry=geom) + # If there is an id_field in the data, set it as index + # instead of the default numerical index + if self.id_field in gdf.columns: + gdf = gdf.set_index(self.id_field, drop=False) LOGGER.debug('results computed') result = gdf.__geo_interface__ @@ -446,8 +572,9 @@ def _response_feature_hits(self, filter): """ try: - scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, - filter=filter) + scanner = pyarrow.dataset.Scanner.from_dataset( + self.ds, filter=filter + ) return { 'type': 'FeatureCollection', 'numberMatched': scanner.count_rows(), diff --git a/tests/data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet b/tests/data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet new file mode 100644 index 0000000000000000000000000000000000000000..cce77baad2acf019d9d89d6509076f14996e6692 GIT binary patch literal 1861 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


oXXe4%Bf;S?#CeMO(stW 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 literal 0 HcmV?d00001 diff --git a/tests/data/random.parquet b/tests/data/parquet/naive/random.parquet similarity index 100% rename from tests/data/random.parquet rename to tests/data/parquet/naive/random.parquet diff --git a/tests/data/random_nocrs.parquet b/tests/data/parquet/naive/random_nocrs.parquet similarity index 100% rename from tests/data/random_nocrs.parquet rename to tests/data/parquet/naive/random_nocrs.parquet diff --git a/tests/data/random_nogeom.parquet b/tests/data/parquet/naive/random_nogeom.parquet similarity index 100% rename from tests/data/random_nogeom.parquet rename to tests/data/parquet/naive/random_nogeom.parquet diff --git a/tests/provider/test_filesystem_provider.py b/tests/provider/test_filesystem_provider.py index 824c23eb9..f1cfffcf0 100644 --- a/tests/provider/test_filesystem_provider.py +++ b/tests/provider/test_filesystem_provider.py @@ -54,7 +54,7 @@ def test_query(config): r = p.get_data_path(baseurl, urlpath, dirpath) - assert len(r['links']) == 13 + assert len(r['links']) == 14 r = p.get_data_path(baseurl, urlpath, '/poi_portugal') diff --git a/tests/provider/test_parquet_provider.py b/tests/provider/test_parquet_provider.py index 736e3dff4..6d45a51d7 100644 --- a/tests/provider/test_parquet_provider.py +++ b/tests/provider/test_parquet_provider.py @@ -5,6 +5,7 @@ # # Copyright (c) 2024 Leo Ghignone # Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Colton Loftus # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -29,6 +30,8 @@ # # ================================================================= +from copy import copy + import pytest from pygeoapi.provider.base import ProviderItemNotFoundError @@ -36,15 +39,6 @@ from ..util import get_test_file_path -path = get_test_file_path( - 'data/random.parquet') - -path_nogeom = get_test_file_path( - 'data/random_nogeom.parquet') - -path_nocrs = get_test_file_path( - 'data/random_nocrs.parquet') - @pytest.fixture() def config_parquet(): @@ -52,13 +46,12 @@ def config_parquet(): 'name': 'Parquet', 'type': 'feature', 'data': { - 'source_type': 'Parquet', - 'source': path, + 'source': get_test_file_path('data/parquet/naive/random.parquet'), }, 'id_field': 'id', 'time_field': 'time', 'x_field': 'lon', - 'y_field': 'lat', + 'y_field': 'lat' } @@ -68,8 +61,8 @@ def config_parquet_nogeom_notime(): 'name': 'ParquetNoGeomNoTime', 'type': 'feature', 'data': { - 'source_type': 'Parquet', - 'source': path_nogeom, + 'source': get_test_file_path( + 'data/parquet/naive/random_nogeom.parquet') }, 'id_field': 'id' } @@ -81,162 +74,267 @@ def config_parquet_nocrs(): 'name': 'ParquetNoCrs', 'type': 'feature', 'data': { - 'source_type': 'Parquet', - 'source': path_nocrs, + 'source': get_test_file_path( + 'data/parquet/naive/random_nocrs.parquet') }, 'id_field': 'id', 'time_field': 'time', 'x_field': 'lon', - 'y_field': 'lat', + 'y_field': 'lat' + } + + +@pytest.fixture +def geoparquet_no_bbox(): + # Data originating from + # https://github.com/opengeospatial/geoparquet/blob/main/test_data/data-polygon-encoding_wkb.parquet + + # As CSV: + # "col","geometry" + # 0,"POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))" + # 1,"POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), (20 30, 35 35, 30 20, 20 30))" # noqa + # 2,"POLYGON EMPTY" + # 3, + return { + 'name': 'GeoparquetNoBbox', + 'type': 'feature', + 'data': { + 'source': get_test_file_path( + 'data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet' # noqa + ) + } + } + + +@pytest.fixture +def geoparquet_with_bbox(): + # Geneated with the overture python CLI + # overturemaps download --bbox=-74,40.98,-73.98,41 -f geoparquet --type=building -o nyc_subset_overture.parquet # noqa + return { + 'name': 'GeoparquetWithBbox', + 'type': 'feature', + 'data': { + 'source': get_test_file_path( + 'data/parquet/geoparquet1.1/nyc_subset_overture.parquet' + ) + } } -def test_get_fields(config_parquet): - """Testing field types""" - - p = ParquetProvider(config_parquet) - results = p.get_fields() - assert results['lat']['type'] == 'number' - assert results['lon']['format'] == 'double' - assert results['time']['format'] == 'date-time' - - -def test_get(config_parquet): - """Testing query for a specific object""" - - p = ParquetProvider(config_parquet) - result = p.get('42') - assert result['id'] == '42' - assert result['properties']['lon'] == 4.947447 - - -def test_get_not_existing_feature_raise_exception( - config_parquet -): - """Testing query for a not existing object""" - p = ParquetProvider(config_parquet) - with pytest.raises(ProviderItemNotFoundError): - p.get(-1) - - -def test_query_hits(config_parquet): - """Testing query on entire collection for hits""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(resulttype='hits') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 0 - hits = feature_collection.get('numberMatched') - assert hits is not None - assert hits == 100 - - -def test_query_bbox_hits(config_parquet): - """Testing query for a valid JSON object with geometry""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query( - bbox=[100, -50, 150, 0], - resulttype='hits') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 0 - hits = feature_collection.get('numberMatched') - assert hits is not None - assert hits == 6 - - -def test_query_with_limit(config_parquet): - """Testing query for a valid JSON object with geometry""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(limit=2, resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 2 - hits = feature_collection.get('numberMatched') - assert hits > 2 - feature = features[0] - properties = feature.get('properties') - assert properties is not None - geometry = feature.get('geometry') - assert geometry is not None - - -def test_query_with_offset(config_parquet): - """Testing query for a valid JSON object with geometry""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(offset=20, limit=10, resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 10 - hits = feature_collection.get('numberMatched') - assert hits > 30 - feature = features[0] - properties = feature.get('properties') - assert properties is not None - assert feature['id'] == '21' - assert properties['lat'] == 66.264988 - geometry = feature.get('geometry') - assert geometry is not None - - -def test_query_with_property(config_parquet): - """Testing query for a valid JSON object with property filter""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query( - resulttype='results', - properties=[('lon', -12.855022)]) - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 1 - for feature in features: - assert feature['properties']['lon'] == -12.855022 - - -def test_query_with_skip_geometry(config_parquet): - """Testing query for a valid JSON object with property filter""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(skip_geometry=True) - for feature in feature_collection['features']: - assert feature.get('geometry') is None - - -def test_query_with_datetime(config_parquet): - """Testing query for a valid JSON object with time""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query( - datetime_='2022-05-01T00:00:00Z/2022-05-31T23:59:59Z') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 7 - for feature in feature_collection['features']: - time = feature['properties'][config_parquet['time_field']] - assert time.year == 2022 - assert time.month == 5 - - -def test_query_nogeom(config_parquet_nogeom_notime): - """Testing query for a valid JSON object without geometry""" - - p = ParquetProvider(config_parquet_nogeom_notime) - feature_collection = p.query(resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - assert len(feature_collection.get('features')) > 0 - for feature in feature_collection['features']: - assert feature.get('geometry') is None - - -def test_query_nocrs(config_parquet_nocrs): - """Testing a parquet provider without CRS""" - - p = ParquetProvider(config_parquet_nocrs) - results = p.get_fields() - assert results['lat']['type'] == 'number' - assert results['lon']['format'] == 'double' - assert results['time']['format'] == 'date-time' +class TestParquetProviderWithNaiveOrMissingGeometry: + """Tests for parquet that do not comply to geoparquet standard""" + + def test_get_fields(self, config_parquet): + """Testing field types""" + + p = ParquetProvider(config_parquet) + assert p.bbox_filterable + assert p.has_geometry + assert not p.has_bbox_column + results = p.get_fields() + assert results['lat']['type'] == 'number' + assert results['lon']['format'] == 'double' + assert results['time']['format'] == 'date-time' + + def test_get(self, config_parquet): + """Testing query for a specific object""" + + p = ParquetProvider(config_parquet) + result = p.get('42') + assert result['id'] == '42' + assert result['properties']['lon'] == 4.947447 + + def test_get_not_existing_feature_raise_exception( + self, config_parquet + ): + """Testing query for a not existing object""" + p = ParquetProvider(config_parquet) + with pytest.raises(ProviderItemNotFoundError): + p.get(-1) + + def test_query_hits(self, config_parquet): + """Testing query on entire collection for hits""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(resulttype='hits') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 0 + hits = feature_collection.get('numberMatched') + assert hits is not None + assert hits == 100 + + def test_query_bbox_hits(self, config_parquet): + """Testing query for a valid JSON object with geometry""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query( + bbox=[100, -50, 150, 0], + resulttype='hits') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 0 + hits = feature_collection.get('numberMatched') + assert hits is not None + assert hits == 6 + + def test_query_with_limit(self, config_parquet): + """Testing query for a valid JSON object with geometry""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(limit=2, resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 2 + hits = feature_collection.get('numberMatched') + assert hits > 2 + feature = features[0] + properties = feature.get('properties') + assert properties is not None + geometry = feature.get('geometry') + assert geometry is not None + + def test_query_with_offset(self, config_parquet): + """Testing query for a valid JSON object with geometry""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(offset=20, limit=10, resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 10 + hits = feature_collection.get('numberMatched') + assert hits > 30 + feature = features[0] + properties = feature.get('properties') + assert properties is not None + assert feature['id'] == '21' + assert properties['lat'] == 66.264988 + geometry = feature.get('geometry') + assert geometry is not None + + def test_query_with_property(self, config_parquet): + """Testing query for a valid JSON object with property filter""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query( + resulttype='results', + properties=[('lon', -12.855022)]) + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 1 + for feature in features: + assert feature['properties']['lon'] == -12.855022 + + def test_query_with_skip_geometry(self, config_parquet): + """Testing query for a valid JSON object with property filter""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(skip_geometry=True) + for feature in feature_collection['features']: + assert feature.get('geometry') is None + + def test_query_with_datetime(self, config_parquet): + """Testing query for a valid JSON object with time""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query( + datetime_='2022-05-01T00:00:00Z/2022-05-31T23:59:59Z') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 7 + for feature in feature_collection['features']: + time = feature['properties'][config_parquet['time_field']] + assert time.year == 2022 + assert time.month == 5 + + def test_query_nogeom(self, config_parquet_nogeom_notime): + """Testing query for a valid JSON object without geometry""" + + p = ParquetProvider(config_parquet_nogeom_notime) + assert not p.has_geometry + assert not p.bbox_filterable + feature_collection = p.query(resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + assert len(feature_collection.get('features')) > 0 + for feature in feature_collection['features']: + assert feature.get('geometry') is None + + def test_query_nocrs(self, config_parquet_nocrs): + """Testing a parquet provider without CRS""" + + p = ParquetProvider(config_parquet_nocrs) + assert p.bbox_filterable + assert p.has_geometry + assert not p.has_bbox_column + results = p.get_fields() + assert results['lat']['type'] == 'number' + assert results['lon']['format'] == 'double' + assert results['time']['format'] == 'date-time' + + +class TestParquetProviderWithGeoparquetMetadata: + + def test_file_without_bbox_without_id_specified(self, geoparquet_no_bbox): + + p = ParquetProvider(geoparquet_no_bbox) + assert not p.bbox_filterable + assert not p.has_bbox_column + assert p.id_field is None + results = p.get_fields() + assert results['col']['type'] == 'integer' + + feature_collection = p.query(resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + assert feature_collection['features'][0]['geometry']['coordinates'] == ( # noqa + ( + ((30, 10), (40, 40), (20, 40), (10, 20), (30, 10)),) + ) + assert feature_collection['features'][0]['properties']['col'] == 0 + + def test_file_without_bbox_with_id_specified(self, geoparquet_no_bbox): + config = copy(geoparquet_no_bbox) + config['id_field'] = 'col' + + p = ParquetProvider( + config + ) + results = p.get_fields() + assert p.id_field == 'col' + assert results['col']['type'] == 'integer' + + feature_collection = p.query(resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + assert feature_collection['features'][0]['geometry']['coordinates'] == ( # noqa + (((30, 10), (40, 40), (20, 40), (10, 20), (30, 10)),) + ) + assert feature_collection['features'][0]['properties']['col'] == 0 + assert feature_collection['features'][0]['id'] == '0' + + def test_get_by_id(self, geoparquet_no_bbox): + + config = copy(geoparquet_no_bbox) + config['id_field'] = 'col' + p = ParquetProvider( + config + ) + + feature = p.get('2') + assert feature.get('type') == 'Feature' + assert feature['geometry'] is None + + def test_file_with_bbox(self, geoparquet_with_bbox): + + p = ParquetProvider(geoparquet_with_bbox) + assert p.has_bbox_column + assert p.bbox_filterable + assert p.has_geometry + + hits = p.query(resulttype='hits')['numberMatched'] + assert hits == 679 + + huge_bbox = p.query(bbox=[-90, -90, 90, 90], resulttype='hits')[ + 'numberMatched' + ] + dataset_bounds = p.query(bbox=[-74.1, 40.97, -73.95, 41.1], + resulttype='hits')['numberMatched'] + assert huge_bbox == dataset_bounds From 939d37325aa944322dc5b61cfe3a512f6e83d4dc Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 9 Mar 2026 21:42:40 -0400 Subject: [PATCH 41/41] OAProc: safeguard link rel check (#2282) (#2283) * OAProc: safeguard link rel check (#2282) * fix ref --- pygeoapi/api/processes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index a3167cb7e..8fdde5b61 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -724,11 +724,11 @@ def get_oas_30(cfg: dict, locale: str 'externalDocs': {} } for link in p.metadata.get('links', []): - if link['type'] == 'information': + if link.get('rel', '') == 'information': translated_link = l10n.translate(link, locale) tag['externalDocs']['description'] = translated_link[ - 'type'] - tag['externalDocs']['url'] = translated_link['url'] + 'rel'] + tag['externalDocs']['url'] = translated_link['href'] break if len(tag['externalDocs']) == 0: del tag['externalDocs']