Repository: dcs4cop/xcube

Issue:
Provide a Catalog API server
**Is your feature request related to a problem? Please describe.**

The API served by `xcube serve` should be extended by a catalog API. This is urgently required to let users query (interactively) what datasets and variables are provided, and how they are named so they can be referenced, e.g.
- to open and access the actual data;
- to configure the input sections in processing requests for the `xcube gen2` tool.

Individual data stores can already describe themselves and the contained datasets (#307 and #369), but this is not yet possible for a pool of data stores (#364). 

**Describe the solution you'd like**

* Compatibility of queries with some standard, e.g. [OpenSearch Geo and Time Extensions](https://www.ogc.org/standards/opensearchgeo)
* Compatibility of results with some metadata standard, e.g. [STAC Collection](https://github.com/radiantearth/stac-spec/blob/master/collection-spec/collection-spec.md).
* Implemented as a plugable extension to existing `xcube serve` tool, i.e. we need to specify server API extensions so plugins can contribute.
* Allow `xube serve` to use configured data store pools the same way it is done for the `xcube gen --stores <store-pool.yaml> ...`.



Discussion:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 5d369572..de453dfc 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,12 @@
 ## Changes in 0.13.1 (in development)
 
+### Enhancements
+
+* Added a catalog API compliant to [STAC](https://stacspec.org/en/) to 
+  xcube server. (#455)
+  It serves a single collection named "datasets" whose items are the
+  datasets published by the service.
+
 ### Fixes
 
 * The xcube server configuration parameters `url_prefix` and 
diff --git a/xcube/plugin.py b/xcube/plugin.py
index b7dff77c..d4cf91f3 100644
--- a/xcube/plugin.py
+++ b/xcube/plugin.py
@@ -226,6 +226,7 @@ def _register_server_apis(ext_registry: extension.ExtensionRegistry):
         'tiles',
         'timeseries',
         'volumes',
+        'ows.stac',
         'ows.wmts',
         's3',
         'viewer',
diff --git a/xcube/webapi/ows/stac/__init__.py b/xcube/webapi/ows/stac/__init__.py
new file mode 100644
index 00000000..23705203
--- /dev/null
+++ b/xcube/webapi/ows/stac/__init__.py
@@ -0,0 +1,22 @@
+# The MIT License (MIT)
+# Copyright (c) 2022 by the xcube team and contributors
+#
+# 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 .routes import api
diff --git a/xcube/webapi/ows/stac/api.py b/xcube/webapi/ows/stac/api.py
new file mode 100644
index 00000000..d45c2f83
--- /dev/null
+++ b/xcube/webapi/ows/stac/api.py
@@ -0,0 +1,32 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 xcube.server.api import Api
+from .config import CONFIG_SCHEMA
+from .context import StacContext
+
+api = Api(
+    'ows.stac',
+    description='xcube OGC STAC API',
+    required_apis=['datasets'],
+    create_ctx=StacContext,
+    config_schema=CONFIG_SCHEMA,
+)
diff --git a/xcube/webapi/ows/stac/config.py b/xcube/webapi/ows/stac/config.py
new file mode 100644
index 00000000..6ec06779
--- /dev/null
+++ b/xcube/webapi/ows/stac/config.py
@@ -0,0 +1,58 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 xcube.util.jsonschema import JsonObjectSchema
+from xcube.util.jsonschema import JsonStringSchema
+
+DEFAULT_CATALOG_ID = "xcube-server"
+DEFAULT_CATALOG_TITLE = "xcube Server"
+DEFAULT_CATALOG_DESCRIPTION = "Catalog of datasets served by xcube."
+
+DEFAULT_COLLECTION_ID = "datasets"
+DEFAULT_COLLECTION_TITLE = "Datasets"
+DEFAULT_COLLECTION_DESCRIPTION = ""
+
+COLLECTION_SCHEMA = JsonObjectSchema(
+    properties=dict(
+        Identifier=JsonStringSchema(default=DEFAULT_COLLECTION_ID),
+        Title=JsonStringSchema(default=DEFAULT_COLLECTION_TITLE),
+        Description=JsonStringSchema(default=DEFAULT_COLLECTION_DESCRIPTION),
+    ),
+    additional_properties=False,
+    required=[],
+)
+
+STAC_SCHEMA = JsonObjectSchema(
+    properties=dict(
+        Identifier=JsonStringSchema(default=DEFAULT_CATALOG_ID),
+        Title=JsonStringSchema(default=DEFAULT_CATALOG_TITLE),
+        Description=JsonStringSchema(default=DEFAULT_CATALOG_DESCRIPTION),
+        Collection=COLLECTION_SCHEMA,
+    ),
+    additional_properties=False,
+    required=[],
+)
+
+CONFIG_SCHEMA = JsonObjectSchema(
+    properties=dict(
+        STAC=STAC_SCHEMA,
+    )
+)
diff --git a/xcube/webapi/ows/stac/context.py b/xcube/webapi/ows/stac/context.py
new file mode 100644
index 00000000..ffc18d0f
--- /dev/null
+++ b/xcube/webapi/ows/stac/context.py
@@ -0,0 +1,38 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 xcube.server.api import Context
+from xcube.webapi.common.context import ResourcesContext
+
+from ...datasets.context import DatasetsContext
+
+
+class StacContext(ResourcesContext):
+
+    def __init__(self, server_ctx: Context):
+        super().__init__(server_ctx)
+        self._datasets_ctx = server_ctx.get_api_ctx('datasets',
+                                                    cls=DatasetsContext)
+
+    @property
+    def datasets_ctx(self) -> DatasetsContext:
+        return self._datasets_ctx
diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py
new file mode 100644
index 00000000..dd555ace
--- /dev/null
+++ b/xcube/webapi/ows/stac/controllers.py
@@ -0,0 +1,373 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 datetime
+from typing import Hashable
+
+import pyproj
+import xarray as xr
+
+from xcube.core.gridmapping import CRS_CRS84
+from xcube.server.api import ApiError
+from xcube.server.api import ServerConfig
+from .config import DEFAULT_CATALOG_DESCRIPTION
+from .config import DEFAULT_CATALOG_ID
+from .config import DEFAULT_CATALOG_TITLE
+from .config import DEFAULT_COLLECTION_DESCRIPTION
+from .config import DEFAULT_COLLECTION_ID
+from .config import DEFAULT_COLLECTION_TITLE
+from ...datasets.context import DatasetsContext
+
+STAC_VERSION = '0.9.0'
+
+
+# noinspection PyUnusedLocal
+def get_root(ctx: DatasetsContext, base_url: str):
+    c_id, c_title, c_description = _get_catalog_metadata(ctx.config)
+    return {
+        "stac_version": STAC_VERSION,
+        "id": c_id,
+        "title": c_title,
+        "description": c_description,
+        "links": [
+            {
+                "rel": "self",
+                "href": f'{base_url}/catalog',
+                "type": "application/json",
+                "title": "this document"
+            },
+            {
+                "rel": "service-desc",
+                "href": f'{base_url}/openapi.json',
+                "type": "application/vnd.oai.openapi+json;version=3.0",
+                "title": "the API definition"
+            },
+            {
+                "rel": "service-doc",
+                "href": f'{base_url}/openapi.html',
+                "type": "text/html",
+                "title": "the API documentation"
+            },
+            {
+                "rel": "conformance",
+                "href": f'{base_url}/catalog/conformance',
+                "type": "application/json",
+                "title": "OGC API conformance classes"
+                         " implemented by this server"
+            },
+            {
+                "rel": "data",
+                "href": f'{base_url}/catalog/collections',
+                "type": "application/json",
+                "title": "Information about the feature collections"
+            },
+            {
+                "rel": "search",
+                "href": f'{base_url}/catalog/search',
+                "type": "application/json",
+                "title": "Search across feature collections"
+            }
+        ],
+    }
+
+
+# noinspection PyUnusedLocal
+def get_conformance(ctx: DatasetsContext):
+    return {
+        "conformsTo": [
+            # TODO: fix this list
+            "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
+            "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30",
+            "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html",
+            "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson"
+        ]
+    }
+
+
+def get_collections(ctx: DatasetsContext, base_url: str):
+    return {
+        "collections": [
+            _get_datasets_collection(ctx, base_url)
+        ]
+    }
+
+
+def get_collection(ctx: DatasetsContext,
+                   base_url: str,
+                   collection_id: str):
+    _assert_valid_collection(ctx, collection_id)
+    return _get_datasets_collection(ctx, base_url, full=True)
+
+
+def get_collection_items(ctx: DatasetsContext,
+                         base_url: str,
+                         collection_id: str):
+    _assert_valid_collection(ctx, collection_id)
+    features = []
+    for dataset_config in ctx.get_dataset_configs():
+        dataset_id = dataset_config["Identifier"]
+        feature = _get_dataset_feature(ctx,
+                                       base_url,
+                                       dataset_id,
+                                       full=False)
+        features.append(feature)
+    return {
+        "type": "FeatureCollection",
+        "features": features,
+        "timeStamp": _utc_now(),
+        "numberMatched": len(features),
+        "numberReturned": len(features),
+    }
+
+
+def get_collection_item(ctx: DatasetsContext,
+                        base_url: str,
+                        collection_id: str,
+                        feature_id: str):
+    _assert_valid_collection(ctx, collection_id)
+    return _get_dataset_feature(ctx, base_url, feature_id, full=True)
+
+
+# noinspection PyUnusedLocal
+def search(ctx: DatasetsContext, base_url: str):
+    # TODO: implement me!
+    return {}
+
+
+# noinspection PyUnusedLocal
+def _get_datasets_collection(ctx: DatasetsContext,
+                             base_url: str,
+                             full: bool = False):
+    c_id, c_title, c_description = _get_collection_metadata(ctx.config)
+    return {
+        "stac_version": STAC_VERSION,
+        "stac_extensions": ["xcube"],
+        "id": c_id,
+        "title": c_title,
+        "description": c_description,
+        "license": "proprietary",
+        "keywords": [],
+        "providers": [],
+        "extent": {},
+        "summaries": {},
+        "links": [
+            {
+                "rel": "self",
+                "href": f"{base_url}/catalog/collections/{c_id}"
+            },
+            {
+                "rel": "root",
+                "href": f"{base_url}/catalog/collections"
+            },
+            # {
+            #     "rel": "license",
+            #     "href": ctx.get_url("TODO"),
+            #     "title": "TODO"
+            # }
+        ]
+    }
+
+
+# noinspection PyUnusedLocal
+def _get_dataset_feature(ctx: DatasetsContext,
+                         base_url: str,
+                         dataset_id: str,
+                         full: bool = False):
+    collection_id, _, _ = _get_collection_metadata(ctx.config)
+
+    ml_dataset = ctx.get_ml_dataset(dataset_id)
+    dataset = ml_dataset.base_dataset
+
+    variables = [get_variable_asset(var_name, var)
+                 for var_name, var in dataset.data_vars.items()]
+    coordinates = [get_variable_asset(var_name, var)
+                   for var_name, var in dataset.coords.items()]
+
+    first_var_name = next(iter(variables))["name"]
+    first_var = dataset[first_var_name]
+    first_var_extra_dims = first_var.dims[0:-2]
+
+    thumbnail_query = ''
+    if first_var_extra_dims:
+        thumbnail_query_params = []
+        for dim in first_var_extra_dims:
+            val = 0
+            if dim in dataset:
+                coord = dataset[dim]
+                if coord.ndim == 1 and coord.size > 0:
+                    val = coord[0]
+            thumbnail_query_params.append(f'{dim}={val}')
+        thumbnail_query = '?' + '&'.join(thumbnail_query_params)
+
+    tiles_query = ''
+    if first_var_extra_dims:
+        tiles_query = '?' + '&'.join(
+            ['%s=<%s>' % (d, d) for d in first_var_extra_dims]
+        )
+
+    t = pyproj.Transformer.from_crs(
+        ml_dataset.grid_mapping.crs,
+        CRS_CRS84,
+        always_xy=True
+    )
+    bbox = ml_dataset.grid_mapping.xy_bbox
+    (x1, x2), (y1, y2) = t.transform((bbox[0], bbox[2]), (bbox[1], bbox[3]))
+
+    # TODO: Prefer original storage location.
+    #       The "s3" operation is default.
+    default_storage_url = f"{base_url}/s3/datasets"
+
+    return {
+        "stac_version": STAC_VERSION,
+        "stac_extensions": ["xcube"],
+        "type": "Feature",
+        "id": dataset_id,
+        "bbox": [x1, y1, x2, y2],
+        "geometry": {
+            "type": "Polygon",
+            "coordinates": [
+                [[x1, y1], [x1, y2], [x2, y2], [x2, y1], [x1, y1]],
+            ],
+        },
+        "properties": {
+            "xcube:dimensions": dict(dataset.dims),
+            "xcube:variables": variables,
+            "xcube:coordinates": coordinates,
+            "xcube:attributes": dict(dataset.attrs),
+        },
+        "collection": collection_id,
+        "links": [
+            {
+                "rel": "self",
+                'href': f'{base_url}/catalog/collections/{collection_id}'
+                        f'/items/{dataset_id}'
+            }
+        ],
+        "assets": {
+            "analytic": {
+                "title": f"{dataset_id} data access",
+                "roles": ["data"],
+                "type": "application/zarr",
+                "href": f"{default_storage_url}/{dataset_id}.zarr",
+                "xcube:analytic": {
+                    v['name']: {
+                        "title": f"{v['name']} data access",
+                        "roles": ["data"],
+                        "type": "application/zarr",
+                        "href": f"{default_storage_url}/"
+                                f"{dataset_id}.zarr/{v['name']}"
+                    }
+                    for v in variables
+                }
+            },
+            "visual": {
+                "title": f"{dataset_id} visualisation",
+                "roles": ["visual"],
+                "type": "image/png",
+                "href": (f"{base_url}/tiles/{dataset_id}/<variable>"
+                         + "/{z}/{y}/{x}"
+                         + tiles_query),
+                "xcube:visual": {
+                    v['name']: {
+                        "title": f"{v['name']} visualisation",
+                        "roles": ["visual"],
+                        "type": "image/png",
+                        "href": (
+                                f"{base_url}/tiles/{dataset_id}/{v['name']}"
+                                + "/{z}/{y}/{x}"
+                                + tiles_query),
+                    }
+                    for v in variables
+                }
+            },
+            "thumbnail": {
+                "title": f"{dataset_id} thumbnail",
+                "roles": ["thumbnail"],
+                "type": "image/png",
+                "href": f"{base_url}/tiles/{dataset_id}/{first_var_name}"
+                        f"/0/0/0{thumbnail_query}"
+            }
+        }
+    }
+
+
+def get_variable_asset(var_name: Hashable, var: xr.DataArray):
+    return {
+        "name": str(var_name),
+        "dtype": str(var.dtype),
+        "dims": list(var.dims),
+        "chunks": list(var.chunks) if var.chunks else None,
+        "shape": list(var.shape),
+        "attrs": dict(var.attrs),
+        # "encoding": dict(var.encoding),
+    }
+
+
+def _assert_valid_collection(ctx: DatasetsContext, collection_id: str):
+    c_id, _, _ = _get_collection_metadata(ctx.config)
+    if collection_id != c_id:
+        raise ApiError.NotFound(f'Collection "{collection_id}" not found')
+
+
+def _get_catalog_metadata(config: ServerConfig):
+    stac_config = config.get("STAC", {})
+    catalog_id = stac_config.get(
+        "Identifier", DEFAULT_CATALOG_ID
+    )
+    catalog_title = stac_config.get(
+        "Title", DEFAULT_CATALOG_TITLE
+    )
+    catalog_description = stac_config.get(
+        "Description", DEFAULT_CATALOG_DESCRIPTION
+    )
+    return catalog_id, catalog_title, catalog_description
+
+
+def _get_collection_metadata(config: ServerConfig):
+    stac_config = config.get("STAC", {})
+    collection_config = stac_config.get("Collection", {})
+    collection_id = collection_config.get(
+        "Identifier", DEFAULT_COLLECTION_ID
+    )
+    collection_title = collection_config.get(
+        "Title", DEFAULT_COLLECTION_TITLE
+    )
+    collection_description = collection_config.get(
+        "Description", DEFAULT_COLLECTION_DESCRIPTION
+    )
+    return collection_id, collection_title, collection_description
+
+
+def _utc_now():
+    return datetime \
+               .datetime \
+               .utcnow() \
+               .replace(microsecond=0) \
+               .isoformat() + 'Z'
+
+
+class CollectionNotFoundException(Exception):
+    pass
+
+
+class DimensionNotFoundException(Exception):
+    pass
diff --git a/xcube/webapi/ows/stac/routes.py b/xcube/webapi/ows/stac/routes.py
new file mode 100644
index 00000000..1baccfd1
--- /dev/null
+++ b/xcube/webapi/ows/stac/routes.py
@@ -0,0 +1,122 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 xcube.server.api import ApiHandler
+
+from .api import api
+from .context import StacContext
+from .controllers import get_collection
+from .controllers import get_collection_item
+from .controllers import get_collection_items
+from .controllers import get_collections
+from .controllers import get_conformance
+from .controllers import get_root
+from .controllers import search
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog")
+class CatalogRootHandler(ApiHandler[StacContext]):
+    @api.operation(operation_id="getCatalogRoot",
+                   summary="Get the STAC catalog's root.")
+    async def get(self):
+        result = get_root(self.ctx.datasets_ctx,
+                          self.request.reverse_base_url)
+        return await self.response.finish(result)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog/conformance")
+class CatalogConformanceHandler(ApiHandler[StacContext]):
+    @api.operation(operation_id="getCatalogConformance",
+                   summary="Get the STAC catalog's conformance.")
+    async def get(self):
+        result = get_conformance(self.ctx.datasets_ctx)
+        return await self.response.finish(result)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog/collections")
+class CatalogCollectionsHandler(ApiHandler[StacContext]):
+    @api.operation(operation_id="getCatalogCollections",
+                   summary="Get the STAC catalog's collections.")
+    async def get(self):
+        result = get_collections(self.ctx.datasets_ctx,
+                                 self.request.reverse_base_url)
+        return await self.response.finish(result)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog/collections/{collectionId}")
+class CatalogCollectionHandler(ApiHandler[StacContext]):
+    # noinspection PyPep8Naming
+    @api.operation(operation_id="getCatalogCollection",
+                   summary="Get a STAC catalog collection.")
+    async def get(self, collectionId: str):
+        result = get_collection(self.ctx.datasets_ctx,
+                                self.request.reverse_base_url,
+                                collectionId)
+        return await self.response.finish(result)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog/collections/{collectionId}/items")
+class CatalogCollectionItemsHandler(ApiHandler[StacContext]):
+    # noinspection PyPep8Naming
+    @api.operation(operation_id="getCatalogCollectionItems",
+                   summary="Get the items of a STAC catalog collection.")
+    async def get(self, collectionId: str):
+        result = get_collection_items(self.ctx.datasets_ctx,
+                                      self.request.reverse_base_url,
+                                      collectionId)
+        return await self.response.finish(result)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog/collections/{collectionId}/items/{featureId}")
+class CatalogCollectionItemHandler(ApiHandler[StacContext]):
+    # noinspection PyPep8Naming
+    @api.operation(operation_id="getCatalogCollectionItem",
+                   summary="Get an item of a STAC catalog collection.")
+    async def get(self, collectionId: str, featureId: str):
+        result = get_collection_item(self.ctx.datasets_ctx,
+                                     self.request.reverse_base_url,
+                                     collectionId, featureId)
+        return await self.response.finish(result)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route("/catalog/search")
+class CatalogSearchHandler(ApiHandler[StacContext]):
+
+    @api.operation(operation_id="searchCatalogByKeywords",
+                   summary="Search the STAC catalog by keywords.")
+    async def get(self):
+        # TODO (forman): get search params from query
+        result = search(self.ctx.datasets_ctx, self.request.reverse_base_url)
+        return await self.response.finish(result)
+
+    @api.operation(operation_id="searchCatalogByJSON",
+                   summary="Search the STAC catalog by JSON request body.")
+    async def post(self):
+        # TODO (forman): get search params from request body
+        result = search(self.ctx.datasets_ctx, self.request.reverse_base_url)
+        return await self.response.finish(result)


Test patch:
diff --git a/test/webapi/ows/stac/__init__.py b/test/webapi/ows/stac/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/webapi/ows/stac/test_config.py b/test/webapi/ows/stac/test_config.py
new file mode 100644
index 00000000..cc9e869d
--- /dev/null
+++ b/test/webapi/ows/stac/test_config.py
@@ -0,0 +1,66 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 unittest
+
+import jsonschema.exceptions
+import pytest
+from xcube.webapi.ows.stac.config import CONFIG_SCHEMA
+
+
+class StacContextTest(unittest.TestCase):
+
+    # noinspection PyMethodMayBeStatic
+    def test_validate_instance_ok(self):
+        CONFIG_SCHEMA.validate_instance({
+        })
+        CONFIG_SCHEMA.validate_instance({
+            "STAC": {
+            }
+        })
+        CONFIG_SCHEMA.validate_instance({
+            "STAC": {
+                "Identifier": "my-catalogue",
+                "Title": "My Catalogue",
+                "Description": "My first tiny little Catalogue",
+            }
+        })
+
+    # noinspection PyMethodMayBeStatic
+    def test_validate_instance_fails(self):
+        with pytest.raises(jsonschema.exceptions.ValidationError):
+            CONFIG_SCHEMA.validate_instance({
+                "STAC": {
+                    "Id": "my-catalogue",
+                    "Title": "My Catalogue",
+                    "Description": "My first tiny little Catalogue",
+                }
+            })
+
+        with pytest.raises(jsonschema.exceptions.ValidationError):
+            CONFIG_SCHEMA.validate_instance({
+                "STAC": {
+                    "Identifier": 12,
+                    "Title": "My Catalogue",
+                    "Description": "My first tiny little Catalogue",
+                }
+            })
diff --git a/test/webapi/ows/stac/test_context.py b/test/webapi/ows/stac/test_context.py
new file mode 100644
index 00000000..fd4f9787
--- /dev/null
+++ b/test/webapi/ows/stac/test_context.py
@@ -0,0 +1,44 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 unittest
+from typing import Optional, Union, Mapping, Any
+
+from test.webapi.helpers import get_api_ctx
+from xcube.server.api import Context
+from xcube.webapi.datasets.context import DatasetsContext
+from xcube.webapi.ows.stac.context import StacContext
+
+
+def get_stac_ctx(
+        server_config: Optional[Union[str, Mapping[str, Any]]] = None
+) -> StacContext:
+    return get_api_ctx("ows.stac", StacContext, server_config)
+
+
+class StacContextTest(unittest.TestCase):
+
+    def test_ctx_ok(self):
+        ctx = get_stac_ctx()
+        self.assertIsInstance(ctx.server_ctx, Context)
+        self.assertIsInstance(ctx.datasets_ctx, DatasetsContext)
diff --git a/test/webapi/ows/stac/test_controllers.py b/test/webapi/ows/stac/test_controllers.py
new file mode 100644
index 00000000..e200d0b0
--- /dev/null
+++ b/test/webapi/ows/stac/test_controllers.py
@@ -0,0 +1,159 @@
+# The MIT License (MIT)
+# Copyright (c) 2023 by the xcube team and contributors
+#
+# 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 unittest
+
+from xcube.webapi.ows.stac.config import DEFAULT_CATALOG_DESCRIPTION
+from xcube.webapi.ows.stac.config import DEFAULT_CATALOG_ID
+from xcube.webapi.ows.stac.config import DEFAULT_CATALOG_TITLE
+from xcube.webapi.ows.stac.config import DEFAULT_COLLECTION_DESCRIPTION
+from xcube.webapi.ows.stac.config import DEFAULT_COLLECTION_ID
+from xcube.webapi.ows.stac.config import DEFAULT_COLLECTION_TITLE
+from xcube.webapi.ows.stac.controllers import STAC_VERSION
+from xcube.webapi.ows.stac.controllers import get_collection
+from xcube.webapi.ows.stac.controllers import get_collection_item
+from xcube.webapi.ows.stac.controllers import get_collection_items
+from xcube.webapi.ows.stac.controllers import get_collections
+from xcube.webapi.ows.stac.controllers import get_conformance
+from xcube.webapi.ows.stac.controllers import get_root
+from .test_context import get_stac_ctx
+
+BASE_URL = "http://localhost:8080"
+
+EXPECTED_COLLECTION = {
+    'id': DEFAULT_COLLECTION_ID,
+    'title': DEFAULT_COLLECTION_TITLE,
+    'description': DEFAULT_COLLECTION_DESCRIPTION,
+    'stac_version': STAC_VERSION,
+    'stac_extensions': ['xcube'],
+    'summaries': {},
+    'extent': {},
+    'keywords': [],
+    'license': 'proprietary',
+    'links': [
+        {
+            'href': f'{BASE_URL}/catalog/collections/{DEFAULT_COLLECTION_ID}',
+            'rel': 'self'
+        },
+        {
+            'href': f'{BASE_URL}/catalog/collections',
+            'rel': 'root'
+        }
+    ],
+    'providers': [],
+}
+
+
+class StacControllersTest(unittest.TestCase):
+    def test_get_collection_item(self):
+        result = get_collection_item(get_stac_ctx().datasets_ctx, BASE_URL,
+                                     DEFAULT_COLLECTION_ID, "demo-1w")
+        self.assertIsInstance(result, dict)
+        # TODO (forman): add more assertions
+
+    def test_get_collection_items(self):
+        result = get_collection_items(get_stac_ctx().datasets_ctx, BASE_URL,
+                                      DEFAULT_COLLECTION_ID)
+        self.assertIsInstance(result, dict)
+        self.assertIn('features', result)
+
+        features = result['features']
+        self.assertIsInstance(features, list)
+        self.assertEqual(2, len(features))
+
+        for feature in features:
+            self.assertIsInstance(feature, dict)
+            self.assertEqual('Feature', feature.get('type'))
+            self.assertEqual(DEFAULT_COLLECTION_ID, feature.get('collection'))
+            self.assertIsInstance(feature.get('bbox'), list)
+            self.assertIsInstance(feature.get('properties'), dict)
+            self.assertIsInstance(feature.get('geometry'), dict)
+            self.assertIsInstance(feature.get('assets'), dict)
+
+            self.assertIsInstance(feature.get('id'), str)
+            self.assertIn(feature['id'], {'demo', 'demo-1w'})
+            # TODO (forman): add more assertions
+            # import pprint
+            # pprint.pprint(feature)
+
+    def test_get_collection(self):
+        result = get_collection(get_stac_ctx().datasets_ctx, BASE_URL,
+                                DEFAULT_COLLECTION_ID)
+        self.assertEqual(EXPECTED_COLLECTION, result)
+
+    def test_get_collections(self):
+        result = get_collections(get_stac_ctx().datasets_ctx, BASE_URL)
+        self.assertEqual({'collections': [EXPECTED_COLLECTION]}, result)
+
+    def test_get_conformance(self):
+        result = get_conformance(get_stac_ctx().datasets_ctx)
+        prefix = 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf'
+        self.assertEqual(
+            {
+                'conformsTo': [
+                    f'{prefix}/core',
+                    f'{prefix}/oas30',
+                    f'{prefix}/html',
+                    f'{prefix}/geojson'
+                ]
+            },
+            result
+        )
+
+    def test_get_root(self):
+        result = get_root(get_stac_ctx().datasets_ctx, BASE_URL)
+        self.assertEqual(
+            {
+                'stac_version': STAC_VERSION,
+                'id': DEFAULT_CATALOG_ID,
+                'title': DEFAULT_CATALOG_TITLE,
+                'description': DEFAULT_CATALOG_DESCRIPTION,
+                'links': [
+                    {'rel': 'self',
+                     'href': f'{BASE_URL}/catalog',
+                     'type': 'application/json',
+                     'title': 'this document'},
+                    {'rel': 'service-desc',
+                     'href': f'{BASE_URL}/openapi.json',
+                     'type': 'application/vnd.oai.openapi+json;version=3.0',
+                     'title': 'the API definition'},
+                    {'rel': 'service-doc',
+                     'href': f'{BASE_URL}/openapi.html',
+                     'type': 'text/html',
+                     'title': 'the API documentation'},
+                    {'rel': 'conformance',
+                     'href': f'{BASE_URL}/catalog/conformance',
+                     'type': 'application/json',
+                     'title': 'OGC API conformance classes implemented'
+                              ' by this server'},
+                    {'rel': 'data',
+                     'href': f'{BASE_URL}/catalog/collections',
+                     'type': 'application/json',
+                     'title': 'Information about the feature collections'},
+                    {'rel': 'search',
+                     'href': f'{BASE_URL}/catalog/search',
+                     'type': 'application/json',
+                     'title': 'Search across feature collections'}
+                ]
+            },
+            result
+        )
diff --git a/test/webapi/ows/stac/test_routes.py b/test/webapi/ows/stac/test_routes.py
new file mode 100644
index 00000000..c0057f45
--- /dev/null
+++ b/test/webapi/ows/stac/test_routes.py
@@ -0,0 +1,66 @@
+# The MIT License (MIT)
+# Copyright (c) 2022 by the xcube team and contributors
+#
+# 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 ...helpers import RoutesTestCase
+
+
+class StacRoutesTest(RoutesTestCase):
+    """STAC endpoints smoke tests"""
+
+    def test_fetch_catalog(self):
+        response = self.fetch('/catalog')
+        self.assertResponseOK(response)
+
+    def test_fetch_catalog_conformance(self):
+        response = self.fetch('/catalog/conformance')
+        self.assertResponseOK(response)
+
+    def test_fetch_catalog_collections(self):
+        response = self.fetch('/catalog/collections')
+        self.assertResponseOK(response)
+
+    def test_fetch_catalog_collection(self):
+        response = self.fetch('/catalog/collections/datasets')
+        self.assertResponseOK(response)
+        response = self.fetch('/catalog/collections/datacubes')
+        self.assertResourceNotFoundResponse(response)
+
+    def test_fetch_catalog_collection_items(self):
+        response = self.fetch('/catalog/collections/datasets/items')
+        self.assertResponseOK(response)
+        response = self.fetch('/catalog/collections/datacubes')
+        self.assertResourceNotFoundResponse(response)
+
+    def test_fetch_catalog_collection_item(self):
+        response = self.fetch('/catalog/collections/datasets/items/demo')
+        self.assertResponseOK(response)
+        response = self.fetch('/catalog/collections/datasets/items/demox')
+        self.assertResourceNotFoundResponse(response)
+        response = self.fetch('/catalog/collections/datacubes/items/demo')
+        self.assertResourceNotFoundResponse(response)
+
+    def test_fetch_catalog_search_by_kw(self):
+        response = self.fetch('/catalog/search', method='GET')
+        self.assertResponseOK(response)
+
+    def test_fetch_catalog_search_by_json(self):
+        response = self.fetch('/catalog/search', method='POST')
+        self.assertResponseOK(response)