Repository: dcs4cop/xcube

Issue:
Unify OGC APIs endpoints under a single prefix
We currently have parts of the modular OGC API exposed at various endpoints:

 - STAC under /catalog
 - coverages under /catalog (doesn't make sense semantically, but overlaps with STAC)
 - compute under / (effectively an implementation of processes under a different name)

None of our currently deployed systems currently depend on these endpoints, so rearranging them now shouldn't break anything. Provisionally I suggest `ogc/` as the prefix. This will also provide a home for any future implementations of further parts of the OGC API.

This task will also involve some modifications to the STAC and/or coverages implementation -- probably mainly to make STAC present datacubes as collections rather than as features.

Discussion:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index ad2e64d2..4d9e72da 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,10 +2,14 @@
 
 ### Enhancements
 
-* Added a basic implementation of the draft version of OGC API - Coverages
+* Added a basic implementation of the draft version of OGC API - Coverages.
+* Adapted the STAC implementation to additionally offer datasets as
+  individual collections for better integration with OGC API - Coverages.
 
 ### Fixes
 
+* Fixed incorrect additional variable data in STAC datacube properties.
+
 ### Other changes
 * `update_dataset_attrs` can now also handle datasets with CRS other than 
   WGS84 and update the metadata according to the 
diff --git a/xcube/server/api.py b/xcube/server/api.py
index b83df7be..260cd388 100644
--- a/xcube/server/api.py
+++ b/xcube/server/api.py
@@ -359,6 +359,16 @@ class Api(Generic[ServerContextT]):
         """
         return self._on_stop(server_ctx)
 
+    def endpoints(self) -> list[dict[str, Union[str, list[str]]]]:
+        """Describe endpoints provided by this API
+
+        Returns a list of dictionaries of the form
+        {'path': '/some/path', 'methods': ['get', 'post']}
+
+        :return: a list of dictionaries, each describing an endpoint
+        """
+        return [route.path_and_methods() for route in self.routes]
+
     def _handle_event(self, server_ctx: "Context"):
         """Do nothing."""
 
@@ -661,6 +671,21 @@ class ApiHandler(Generic[ServerContextT], ABC):
         """The response that provides the handler's output."""
         return self._response
 
+    @classmethod
+    def defined_methods(cls) -> list[str]:
+        """List the methods handled by this handler
+
+        Any method with an __openapi__ attribute is assumed to be
+        handled.
+
+        :return: a list of the method names handled by this handler
+        """
+        return [
+            name
+            for name in ['head', 'get', 'post', 'put', 'delete', 'options']
+            if hasattr(getattr(cls, name), '__openapi__')
+        ]
+
     # HTTP methods
 
     def head(self, *args, **kwargs):
@@ -747,6 +772,19 @@ class ApiRoute:
             args += f", handler_kwargs={self.handler_kwargs!r}"
         return f"ApiRoute({args})"
 
+    def path_and_methods(self) -> dict[str, Union[str, list[str]]]:
+        """Describe the path and methods of this route
+
+        Returns a dictionary of the form
+        {'path': '/this/routes/path', 'methods': ['get', 'post']}
+
+        :return: a dictionary describing this route
+        """
+        return {
+            'path': self.path,
+            'methods': self.handler_cls.defined_methods()
+        }
+
 
 class ApiStaticRoute:
     """
diff --git a/xcube/util/jsonschema.py b/xcube/util/jsonschema.py
index d4d406bc..e4038c6a 100644
--- a/xcube/util/jsonschema.py
+++ b/xcube/util/jsonschema.py
@@ -468,7 +468,7 @@ class JsonObjectSchema(JsonSchema):
 
     def to_dict(self) -> Dict[str, Any]:
         d = super().to_dict()
-        if self.properties:
+        if self.properties is not None:
             d.update(properties={k: v.to_dict() for k, v in self.properties.items()})
         if self.additional_properties is not None:
             d.update(additionalProperties=self.additional_properties.to_dict() \
diff --git a/xcube/webapi/ows/coverages/routes.py b/xcube/webapi/ows/coverages/routes.py
index a842a11a..5600db7b 100644
--- a/xcube/webapi/ows/coverages/routes.py
+++ b/xcube/webapi/ows/coverages/routes.py
@@ -34,8 +34,11 @@ from .controllers import (
 )
 
 
+PATH_PREFIX= '/ogc'
+
+
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route('/catalog/collections/{collectionId}/coverage')
+@api.route(PATH_PREFIX + '/collections/{collectionId}/coverage')
 class CoveragesCoverageHandler(ApiHandler[CoveragesContext]):
     """
     Return coverage data
@@ -81,7 +84,7 @@ class CoveragesCoverageHandler(ApiHandler[CoveragesContext]):
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route('/catalog/collections/{collectionId}/coverage/domainset')
+@api.route(PATH_PREFIX + '/collections/{collectionId}/coverage/domainset')
 class CoveragesDomainsetHandler(ApiHandler[CoveragesContext]):
     """
     Describe the domain set of a coverage
@@ -102,7 +105,7 @@ class CoveragesDomainsetHandler(ApiHandler[CoveragesContext]):
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route('/catalog/collections/{collectionId}/coverage/rangetype')
+@api.route(PATH_PREFIX + '/collections/{collectionId}/coverage/rangetype')
 class CoveragesRangetypeHandler(ApiHandler[CoveragesContext]):
     """
     Describe the range type of a coverage
@@ -123,7 +126,7 @@ class CoveragesRangetypeHandler(ApiHandler[CoveragesContext]):
         return self.response.finish(range_type)
 
 
-@api.route('/catalog/collections/{collectionId}/coverage/metadata')
+@api.route(PATH_PREFIX + '/collections/{collectionId}/coverage/metadata')
 class CoveragesMetadataHandler(ApiHandler[CoveragesContext]):
     """
     Return coverage metadata
@@ -141,7 +144,7 @@ class CoveragesMetadataHandler(ApiHandler[CoveragesContext]):
         )
 
 
-@api.route('/catalog/collections/{collectionId}/coverage/rangeset')
+@api.route(PATH_PREFIX + '/collections/{collectionId}/coverage/rangeset')
 class CoveragesRangesetHandler(ApiHandler[CoveragesContext]):
     """
     Handle rangeset endpoint with a "not supported" response
diff --git a/xcube/webapi/ows/stac/config.py b/xcube/webapi/ows/stac/config.py
index 0a480795..d8c1e427 100644
--- a/xcube/webapi/ows/stac/config.py
+++ b/xcube/webapi/ows/stac/config.py
@@ -26,10 +26,20 @@ DEFAULT_CATALOG_ID = "xcube-server"
 DEFAULT_CATALOG_TITLE = "xcube Server"
 DEFAULT_CATALOG_DESCRIPTION = "Catalog of datasets served by xcube."
 
+# ID, name, and description for the unified collection containing a feature
+# for each datacube
 DEFAULT_COLLECTION_ID = "datacubes"
 DEFAULT_COLLECTION_TITLE = "Data cubes"
 DEFAULT_COLLECTION_DESCRIPTION = "a collection of xcube datasets"
 
+# As well as the unified collection, there's an individual collection for
+# each datacube, with the same name as that datacube, and containing a single
+# feature representing that datacube. This is the name of that single feature.
+DEFAULT_FEATURE_ID = 'datacube'
+
+# Prefix for STAC and OGC endpoints
+PATH_PREFIX = '/ogc'
+
 COLLECTION_SCHEMA = JsonObjectSchema(
     properties=dict(
         Identifier=JsonStringSchema(default=DEFAULT_COLLECTION_ID),
diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py
index 3d70442f..9c5eb1fd 100644
--- a/xcube/webapi/ows/stac/controllers.py
+++ b/xcube/webapi/ows/stac/controllers.py
@@ -21,23 +21,26 @@
 
 
 import datetime
-from typing import Hashable, Any, Optional, Dict, List, Mapping
+from typing import Hashable, Any, Optional, Dict, List, Mapping, Union
+import itertools
 
 import numpy as np
-import pandas as pd
 import pyproj
 import xarray as xr
 
+import xcube
 from xcube.core.gridmapping import CRS_CRS84, GridMapping
 from xcube.server.api import ApiError
 from xcube.server.api import ServerConfig
 from xcube.util.jsonencoder import to_json_value
-from .config import DEFAULT_CATALOG_DESCRIPTION
+from xcube.util.jsonschema import JsonObjectSchema, JsonSchema
+from .config import DEFAULT_CATALOG_DESCRIPTION, DEFAULT_FEATURE_ID
 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 .config import PATH_PREFIX
 from ...datasets.context import DatasetsContext
 
 STAC_VERSION = '1.0.0'
@@ -49,20 +52,41 @@ STAC_EXTENSIONS = [
 # of a value of "datacube:dimensions" or "datacube:variables":
 _MAX_NUM_VALUES = 1000
 
-_CONFORMANCE = [
-    "https://api.stacspec.org/v1.0.0-rc.2/core",
-    "https://api.stacspec.org/v1.0.0-rc.2/ogcapi-features",
-    "https://api.stacspec.org/v1.0.0-rc.1/collections",
-    "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"
-]
+_CONFORMANCE = (
+    ['https://api.geodatacube.example/1.0.0-beta']
+    + [
+        f'https://api.stacspec.org/v1.0.0/{part}'
+        for part in ['core', 'collections', 'ogcapi-features']
+    ]
+    + [
+        f'http://www.opengis.net/spec/ogcapi-{ogcapi}/1.0/conf/{part}'
+        for ogcapi, parts in [
+            ('common-1', ['core', 'json', 'oas30']),
+            ('common-2', ['collections']),
+            ('features-1', ['core', 'oas30', 'html', 'geojson']),
+            ('coverages-1', ['geodata-coverage', 'cisjson', 'coverage-subset',
+                             'oas30']),
+        ]
+        for part in parts
+    ]
+)
 
 
 # noinspection PyUnusedLocal
 def get_root(ctx: DatasetsContext, base_url: str):
+    """Return content for the STAC/OGC root endpoint (a STAC catalogue)
+
+    :param ctx: the datasets context
+    :param base_url: the base URL of the server
+    :return: content for the root endpoint
+    """
     c_id, c_title, c_description = _get_catalog_metadata(ctx.config)
+
+    # If OGC API - Coverages is present, the STAC controller lists the
+    # Coverages endpoints along with its own.
+    endpoint_lists = [a.endpoints() for a in ctx.apis
+                      if a.name in {'ows.stac', 'ows.coverages'}]
+
     return {
         "type": "Catalog",
         "stac_version": STAC_VERSION,
@@ -70,11 +94,15 @@ def get_root(ctx: DatasetsContext, base_url: str):
         "id": c_id,
         "title": c_title,
         "description": c_description,
+        "api_version": "1.0.0",
+        "backend_version": xcube.__version__,
+        "gdc_version": "1.0.0-beta.1",
+        "endpoints": list(itertools.chain.from_iterable(endpoint_lists)),
         "links": [
             _root_link(base_url),
             {
                 "rel": "self",
-                "href": f'{base_url}/catalog',
+                "href": f'{base_url}{PATH_PREFIX}',
                 "type": "application/json",
                 "title": "this document"
             },
@@ -92,26 +120,26 @@ def get_root(ctx: DatasetsContext, base_url: str):
             },
             {
                 "rel": "conformance",
-                "href": f'{base_url}/catalog/conformance',
+                "href": f'{base_url}{PATH_PREFIX}/conformance',
                 "type": "application/json",
                 "title": "OGC API conformance classes"
                          " implemented by this server"
             },
             {
                 "rel": "data",
-                "href": f'{base_url}/catalog/collections',
+                "href": f'{base_url}{PATH_PREFIX}/collections',
                 "type": "application/json",
                 "title": "Information about the feature collections"
             },
             {
                 "rel": "search",
-                "href": f'{base_url}/catalog/search',
+                "href": f'{base_url}{PATH_PREFIX}/search',
                 "type": "application/json",
                 "title": "Search across feature collections"
             },
             {
                 "rel": "child",
-                "href": f'{base_url}/catalog/collections/datacubes',
+                "href": f'{base_url}{PATH_PREFIX}/collections/datacubes',
                 "type": "application/json",
                 "title": DEFAULT_COLLECTION_DESCRIPTION
             }
@@ -122,99 +150,227 @@ def get_root(ctx: DatasetsContext, base_url: str):
 def _root_link(base_url):
     return {
         "rel": "root",
-        "href": f'{base_url}/catalog',
+        "href": f'{base_url}{PATH_PREFIX}',
         "type": "application/json",
-        "title": "root of the STAC catalog"
+        "title": "root of the OGC API and STAC catalog"
     }
 
 
 # noinspection PyUnusedLocal
-def get_conformance(ctx: DatasetsContext):
+def get_conformance() -> dict[str, list[str]]:
+    """Return conformance data for this API implementation
+
+    :return: a dictionary containing a list of conformance specifiers
+    """
     return {"conformsTo": _CONFORMANCE}
 
 
-def get_collections(ctx: DatasetsContext, base_url: str):
+def get_collections(ctx: DatasetsContext, base_url: str) -> dict[str, Any]:
+    """Get all the collections available in the given context
+
+    These include a union collection representing all the datasets,
+    as well as an individual named collection per dataset.
+
+    :param ctx: a datasets context
+    :param base_url: the base URL of the current server
+    :return: a STAC dictionary listing the available collections
+    """
     return {
-        "collections": [
-            _get_datasets_collection(ctx, base_url)
+        "collections": [_get_datasets_collection(ctx, base_url)] + [
+            _get_single_dataset_collection(ctx, base_url, c['Identifier'])
+            for c in ctx.get_dataset_configs()
         ],
         "links": [
             _root_link(base_url),
             {
                 "rel": "self",
                 "type": "application/json",
-                "href": f"{base_url}/catalog/collections"
+                "href": f"{base_url}{PATH_PREFIX}/collections"
             },
             {
                 "rel": "parent",
-                "href": f"{base_url}/catalog"
+                "href": f"{base_url}{PATH_PREFIX}"
             }
         ]
     }
 
 
-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(
+    ctx: DatasetsContext, base_url: str, collection_id: str
+) -> dict:
+    """Return a STAC representation of a collection
+
+    :param ctx: a datasets context
+    :param base_url: the base URL of the current server
+    :param collection_id: the ID of the collection to describe
+    :return: a STAC object representing the collection, if found
+    :raises: ApiError.NotFound if no collection with the given ID exists
+    """
+    all_datasets_collection_id, _, _ = _get_collection_metadata(ctx.config)
+    collection_ids = [c['Identifier'] for c in ctx.get_dataset_configs()]
+    if collection_id in collection_ids:
+        return _get_single_dataset_collection(
+            ctx, base_url, collection_id, full=True
+        )
+    elif collection_id == all_datasets_collection_id:
+        return _get_datasets_collection(ctx, base_url, full=True)
+    else:
+        raise ApiError.NotFound(f'Collection "{collection_id}" not found')
 
 
-def get_collection_items(ctx: DatasetsContext,
-                         base_url: str,
-                         collection_id: str,
-                         limit: int = 100,
-                         cursor: int = 0):
+def get_single_collection_items(
+    ctx: DatasetsContext, base_url: str, collection_id: str
+) -> dict:
+    """Get the singleton item list for a single-dataset collection
+
+    :param ctx: a datasets context
+    :param base_url: the base URL of the current server
+    :param collection_id: the ID of a single-dataset collection
+    :return: a FeatureCollection dictionary with a singleton feature
+        list containing a feature for the requested dataset
+    """
+    feature = _get_dataset_feature(
+        ctx, base_url, collection_id, collection_id, DEFAULT_FEATURE_ID,
+        full=False
+    )
+    self_href = f"{base_url}{PATH_PREFIX}/collections/{collection_id}/items"
+    return {
+        'type': 'FeatureCollection',
+        'features': [feature],
+        'links': [
+            _root_link(base_url),
+            {
+                'rel': 'self',
+                'type': 'application/json',
+                'href': self_href
+            }
+        ],
+        'timeStamp': datetime.datetime.now().astimezone().isoformat()
+    }
+
+
+def get_datasets_collection_items(
+    ctx: DatasetsContext,
+    base_url: str,
+    collection_id: str,
+    limit: int = 100,
+    cursor: int = 0,
+) -> dict:
+    """Get the items in the unified datasets collection
+
+    :param ctx: a datasets context
+    :param base_url: base URL of the current server
+    :param collection_id: the ID of the unified datasets collection
+    :param limit: the maximum number of items to return
+    :param cursor: the index of the first item to return
+    :return: A STAC dictionary of the items in the unified datasets collection,
+        limited by the specified limit and cursor values
+    """
     _assert_valid_collection(ctx, collection_id)
     all_configs = ctx.get_dataset_configs()
-    configs = all_configs[cursor:(cursor + limit)]
+    configs = all_configs[cursor : (cursor + limit)]
     features = []
     for dataset_config in configs:
         dataset_id = dataset_config["Identifier"]
-        feature = _get_dataset_feature(ctx,
-                                       base_url,
-                                       dataset_id,
-                                       full=False)
+        feature = _get_dataset_feature(
+            ctx, base_url, dataset_id, collection_id, dataset_id, full=False
+        )
         features.append(feature)
-    self_href = f"{base_url}/catalog/collections/{collection_id}/items"
+    self_href = f"{base_url}{PATH_PREFIX}/collections/{collection_id}/items"
     links = [
-            _root_link(base_url),
+        _root_link(base_url),
+        {"rel": "self", "type": "application/json", "href": self_href},
+    ]
+    if cursor + limit < len(all_configs):
+        links.append(
             {
-                "rel": "self",
-                "type": "application/json",
-                "href": self_href
+                'rel': 'next',
+                'href': self_href + f'?cursor={cursor + limit}&limit={limit}',
             }
-        ]
-    if cursor + limit < len(all_configs):
-        links.append({
-            'rel': 'next',
-            'href': self_href + f'?cursor={cursor + limit}&limit={limit}'
-        })
+        )
     if cursor > 0:
         new_cursor = cursor - limit
         if new_cursor < 0:
             new_cursor = 0
         cursor_param = 'cursor={new_cursor}&' if new_cursor > 0 else ''
-        links.append({
-            'rel': 'previous',
-            'href': self_href + f'?{cursor_param}limit={limit}'
-        })
+        links.append(
+            {
+                'rel': 'previous',
+                'href': self_href + f'?{cursor_param}limit={limit}',
+            }
+        )
     return {
         "type": "FeatureCollection",
         "features": features,
         "timeStamp": _utc_now(),
         "numberMatched": len(features),
         "numberReturned": len(features),
-        "links": links
+        "links": links,
     }
 
 
-def get_collection_item(ctx: DatasetsContext,
-                        base_url: str,
-                        collection_id: str,
-                        feature_id: str):
+def get_collection_item(
+    ctx: DatasetsContext, base_url: str, collection_id: str, feature_id: str
+) -> dict:
+    """Get a specified item from a specified collection
+
+    It is expected that either the collection ID will be that of the
+    unified dataset collection and the feature ID will be the dataset ID,
+    or that the collection ID will be the dataset ID and the feature ID
+    will be the default feature ID for a single-collection dataset.
+
+    :param ctx: a datasets context
+    :param base_url: the base URL of the current server
+    :param collection_id: the ID of the unified datasets collection or of
+        a single-dataset collection
+    :param feature_id: the ID of a single dataset within the unified
+       collection or of the default feature within a single-dataset collection
+    :return: a STAC object representing the specified item, if found
+    :raises: ApiError.NotFound, if the specified item is not found
+    """
+    dataset_ids = {c['Identifier'] for c in ctx.get_dataset_configs()}
+
+    feature_not_found = ApiError.NotFound(
+        f'Feature "{feature_id}" not found in collection {collection_id}.'
+    )
+    if collection_id == DEFAULT_COLLECTION_ID:
+        if feature_id in dataset_ids:
+            return _get_dataset_feature(
+                ctx, base_url, feature_id, collection_id, feature_id, full=True
+            )
+        else:
+            raise feature_not_found
+    elif collection_id in dataset_ids:
+        if feature_id == DEFAULT_FEATURE_ID:
+            return _get_dataset_feature(
+                ctx,
+                base_url,
+                collection_id,
+                collection_id,
+                feature_id,
+                full=True,
+            )
+        else:
+            raise feature_not_found
+    else:
+        raise ApiError.NotFound(f'Collection "{collection_id}" not found.')
+
+
+def get_collection_queryables(
+    ctx: DatasetsContext, collection_id: str
+) -> dict:
+    """ Get a JSON schema of queryable parameters for the specified collection
+
+    :param ctx: a datasets context
+    :param collection_id: the ID of a collection
+    :return: a JSON schema of queryable parameters, if the collection was found
+    :raises: ApiError.NotFOund, if the collection was not round
+    """
     _assert_valid_collection(ctx, collection_id)
-    return _get_dataset_feature(ctx, base_url, feature_id, full=True)
+    schema = JsonObjectSchema(
+        title=collection_id, properties={}, additional_properties=False
+    )
+    return schema.to_dict()
 
 
 # noinspection PyUnusedLocal
@@ -226,7 +382,7 @@ def search(ctx: DatasetsContext, base_url: str):
 # noinspection PyUnusedLocal
 def _get_datasets_collection(ctx: DatasetsContext,
                              base_url: str,
-                             full: bool = False):
+                             full: bool = False) -> dict:
     c_id, c_title, c_description = _get_collection_metadata(ctx.config)
     return {
         "stac_version": STAC_VERSION,
@@ -250,17 +406,17 @@ def _get_datasets_collection(ctx: DatasetsContext,
             {
                 "rel": "self",
                 "type": "application/json",
-                "href": f"{base_url}/catalog/collections/{c_id}",
+                "href": f"{base_url}{PATH_PREFIX}/collections/{c_id}",
                 "title": "this collection"
             },
             {
                 "rel": "parent",
-                "href": f"{base_url}/catalog/collections",
+                "href": f"{base_url}{PATH_PREFIX}/collections",
                 "title": "collections list"
             },
             {
                 "rel": "items",
-                "href": f"{base_url}/catalog/collections/{c_id}/items",
+                "href": f"{base_url}{PATH_PREFIX}/collections/{c_id}/items",
                 "title": "feature collection of data cube items"
             }
             # {
@@ -272,23 +428,154 @@ def _get_datasets_collection(ctx: DatasetsContext,
     }
 
 
+def _get_single_dataset_collection(
+    ctx: DatasetsContext, base_url: str, dataset_id: str, full: bool = False
+) -> dict:
+    ml_dataset = ctx.get_ml_dataset(dataset_id)
+    dataset = ml_dataset.base_dataset
+    grid_bbox = GridBbox(ml_dataset.grid_mapping)
+    time_properties = _get_time_properties(dataset)
+    if {'start_datetime', 'end_datetime'}.issubset(time_properties):
+        time_interval = [
+            time_properties['start_datetime'],
+            time_properties['end_datetime']
+        ]
+    else:
+        time_interval = [
+            time_properties['datetime'],
+            time_properties['datetime']
+        ]
+    result = {
+        'assets': _get_assets(ctx, base_url, dataset_id),
+        'description': dataset_id,
+        'extent': {
+            'spatial': {
+                'bbox': grid_bbox.as_bbox()
+            },
+            'temporal': {
+                'interval': [time_interval]
+            }
+        },
+        'id': dataset_id,
+        'keywords': [],
+        'license': 'proprietary',
+        'links': [
+            _root_link(base_url),
+            {
+                'rel': 'self',
+                'type': 'application/json',
+                'href': f'{base_url}{PATH_PREFIX}/collections/{dataset_id}',
+                'title': 'this collection'
+            },
+            {
+                'rel': 'parent',
+                'href': f'{base_url}{PATH_PREFIX}/collections',
+                'title': 'collections list'
+            },
+            {
+                'rel': 'items',
+                'href': f'{base_url}{PATH_PREFIX}/collections/'
+                        f'{dataset_id}/items',
+                'title': 'feature collection of data cube items'
+            }
+        ],
+        'providers': [],
+        'stac_version': STAC_VERSION,
+        'summaries': {},
+        'title': dataset_id,
+        'type': 'Collection',
+    }
+    result.update(_get_cube_properties(ctx, dataset_id))
+    return result
+
+
+class GridBbox:
+    """Utility class to transform and manipulate bounding box data
+    """
+
+    def __init__(self, grid_mapping: GridMapping):
+        transformer = pyproj.Transformer.from_crs(
+            grid_mapping.crs,
+            CRS_CRS84,
+            always_xy=True
+        )
+        bbox = grid_mapping.xy_bbox
+        (self.x1, self.x2), (self.y1, self.y2) = (
+            transformer.transform((bbox[0], bbox[2]), (bbox[1], bbox[3])))
+
+    def as_bbox(self) -> list:
+        return [self.x1, self.y1, self.x2, self.y2]
+
+    def as_geometry(self) -> dict[str, Union[str, list]]:
+        return {
+            "type": "Polygon",
+            "coordinates": [
+                [[self.x1, self.y1], [self.x1, self.y2], [self.x2, self.y2],
+                 [self.x2, self.y1], [self.x1, self.y1]],
+            ],
+        }
+
+
 # noinspection PyUnusedLocal
 def _get_dataset_feature(ctx: DatasetsContext,
                          base_url: str,
                          dataset_id: str,
-                         full: bool = False):
-    collection_id, _, _ = _get_collection_metadata(ctx.config)
+                         collection_id: str,
+                         feature_id: str,
+                         full: bool = False) -> dict:
+    bbox = GridBbox(ctx.get_ml_dataset(dataset_id).grid_mapping)
 
+    return {
+        "stac_version": STAC_VERSION,
+        "stac_extensions": STAC_EXTENSIONS,
+        "type": "Feature",
+        "id": dataset_id,
+        "bbox": bbox.as_bbox(),
+        "geometry": bbox.as_geometry(),
+        "properties": _get_cube_properties(ctx, dataset_id),
+        "collection": collection_id,
+        "links": [
+            _root_link(base_url),
+            {
+                "rel": "self",
+                "href": f"{base_url}{PATH_PREFIX}/collections/{collection_id}"
+                        f"/items/{feature_id}"
+            },
+            {
+                "rel": "collection",
+                "href": f"{base_url}{PATH_PREFIX}/collections/{collection_id}"
+            },
+            {
+                "rel": "parent",
+                "href": f"{base_url}{PATH_PREFIX}/collections/{collection_id}"
+            }
+        ],
+        "assets": _get_assets(ctx, base_url, dataset_id)
+    }
+
+
+def _get_cube_properties(ctx: DatasetsContext, dataset_id: str):
     ml_dataset = ctx.get_ml_dataset(dataset_id)
     grid_mapping = ml_dataset.grid_mapping
     dataset = ml_dataset.base_dataset
 
-    xcube_coords = _get_xc_variables(dataset.coords)
-    xcube_data_vars = _get_xc_variables(dataset.data_vars)
+    cube_dimensions = get_datacube_dimensions(dataset, grid_mapping)
+
+    return {
+        "cube:dimensions": cube_dimensions,
+        "cube:variables": _get_dc_variables(dataset, cube_dimensions),
+        "xcube:dims": to_json_value(dataset.dims),
+        "xcube:data_vars": _get_xc_variables(dataset.data_vars),
+        "xcube:coords": _get_xc_variables(dataset.coords),
+        "xcube:attrs": to_json_value(dataset.attrs),
+        **(_get_time_properties(dataset)),
+    }
 
-    cube_dimensions = _get_dc_dimensions(dataset, grid_mapping)
-    cube_variables = _get_dc_variables(dataset, cube_dimensions)
 
+def _get_assets(ctx: DatasetsContext, base_url: str, dataset_id: str):
+    ml_dataset = ctx.get_ml_dataset(dataset_id)
+    dataset = ml_dataset.base_dataset
+    xcube_data_vars = _get_xc_variables(dataset.data_vars)
     first_var_name = next(iter(xcube_data_vars))["name"]
     first_var = dataset[first_var_name]
     first_var_extra_dims = first_var.dims[0:-2]
@@ -311,18 +598,58 @@ def _get_dataset_feature(ctx: DatasetsContext,
             ['%s=<%s>' % (d, d) for d in first_var_extra_dims]
         )
 
-    t = pyproj.Transformer.from_crs(
-        grid_mapping.crs,
-        CRS_CRS84,
-        always_xy=True
-    )
-    bbox = 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 {
+        "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 xcube_data_vars
+            }
+        },
+        "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 xcube_data_vars
+            }
+        },
+        "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_time_properties(dataset):
     if 'time' in dataset:
         time_var = dataset['time']
         start_time = to_json_value(time_var[0])
@@ -339,91 +666,7 @@ def _get_dataset_feature(ctx: DatasetsContext,
             # TODO Decide what to use as a fall-back datetime
             'datetime': '2000-01-01T00:00:00Z'
         }
-
-    return {
-        "stac_version": STAC_VERSION,
-        "stac_extensions": STAC_EXTENSIONS,
-        "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": {
-            "cube:dimensions": cube_dimensions,
-            "cube:variables": cube_variables,
-            "xcube:dims": to_json_value(dataset.dims),
-            "xcube:data_vars": xcube_data_vars,
-            "xcube:coords": xcube_coords,
-            "xcube:attrs": to_json_value(dataset.attrs),
-            **time_properties
-        },
-        "collection": collection_id,
-        "links": [
-            _root_link(base_url),
-            {
-                "rel": "self",
-                "href": f"{base_url}/catalog/collections/{collection_id}"
-                        f"/items/{dataset_id}"
-            },
-            {
-                "rel": "collection",
-                "href": f"{base_url}/catalog/collections/{collection_id}"
-            },
-            {
-                "rel": "parent",
-                "href": f"{base_url}/catalog/collections/{collection_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 xcube_data_vars
-                }
-            },
-            "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 xcube_data_vars
-                }
-            },
-            "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}"
-            }
-        }
-    }
+    return time_properties
 
 
 def _get_xc_variables(variables: Mapping[Hashable, xr.DataArray]) \
@@ -450,10 +693,14 @@ def _get_xc_variable(var_name: Hashable, var: xr.DataArray) -> Dict[str, Any]:
     }
 
 
-def _get_dc_dimensions(dataset: xr.Dataset,
-                       grid_mapping: GridMapping) -> Dict[str, Any]:
+def get_datacube_dimensions(dataset: xr.Dataset,
+                            grid_mapping: GridMapping) -> Dict[str, Any]:
     """Create the value of the "datacube:dimensions" property
     for the given *dataset*.
+
+    :param dataset: the dataset to describe
+    :param grid_mapping: the dataset's grid mapping
+    :return: a dictionary of the datacube properties of the dataset
     """
     x_dim_name, y_dim_name = grid_mapping.xy_dim_names
     x_var_name, y_var_name = grid_mapping.xy_var_names
@@ -475,7 +722,7 @@ def _get_dc_dimensions(dataset: xr.Dataset,
         if dim_name not in {x_dim_name, y_dim_name, "time"} \
                 and dim_name in dataset:
             dc_dimensions.update(
-                dim_name=_get_dc_additional_dimension(dataset[dim_name])
+                {dim_name: _get_dc_additional_dimension(dataset[dim_name])}
             )
     return dc_dimensions
 
@@ -525,7 +772,7 @@ def _get_dc_additional_dimension(
         asset["range"] = [to_json_value(var[0]), to_json_value(var[-1])]
         if var.size > 1:
             diff_var = np.diff(var)
-            if np.issubdtype(var.dtype, np.numeric) \
+            if np.issubdtype(var.dtype, np.number) \
                     and np.allclose(np.diff(diff_var), 0):
                 asset["step"] = to_json_value(diff_var[0])
         if "step" not in asset and var.size < _MAX_NUM_VALUES:
@@ -611,8 +858,12 @@ def _get_str_attr(attrs: Dict[str, Any], keys: List[str]) -> Optional[str]:
 
 
 def _assert_valid_collection(ctx: DatasetsContext, collection_id: str):
-    c_id, _, _ = _get_collection_metadata(ctx.config)
-    if collection_id != c_id:
+    # c_id, _, _ = _get_collection_metadata(ctx.config)
+    collection_ids = [c['Identifier'] for c in ctx.get_dataset_configs()]
+    if (
+        collection_id not in collection_ids
+        and collection_id != DEFAULT_COLLECTION_ID
+    ):
         raise ApiError.NotFound(f'Collection "{collection_id}" not found')
 
 
diff --git a/xcube/webapi/ows/stac/routes.py b/xcube/webapi/ows/stac/routes.py
index 0d8d8e2f..6c29c370 100644
--- a/xcube/webapi/ows/stac/routes.py
+++ b/xcube/webapi/ows/stac/routes.py
@@ -19,21 +19,26 @@
 # 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 xcube.server.api import ApiHandler, ApiError
 
 from .api import api
 from .context import StacContext
-from .controllers import get_collection
+from .controllers import get_collection, get_collection_queryables, \
+    get_single_collection_items
 from .controllers import get_collection_item
-from .controllers import get_collection_items
+from .controllers import get_datasets_collection_items
 from .controllers import get_collections
 from .controllers import get_conformance
 from .controllers import get_root
 from .controllers import search
+from .config import (
+    PATH_PREFIX,
+    DEFAULT_COLLECTION_ID,
+)
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog")
+@api.route(PATH_PREFIX + "")
 class CatalogRootHandler(ApiHandler[StacContext]):
     @api.operation(operation_id="getCatalogRoot",
                    summary="Get the STAC catalog's root.")
@@ -44,17 +49,17 @@ class CatalogRootHandler(ApiHandler[StacContext]):
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog/conformance")
+@api.route(PATH_PREFIX + "/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)
+        result = get_conformance()
         return await self.response.finish(result)
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog/collections")
+@api.route(PATH_PREFIX + "/collections")
 class CatalogCollectionsHandler(ApiHandler[StacContext]):
     @api.operation(operation_id="getCatalogCollections",
                    summary="Get the STAC catalog's collections.")
@@ -65,7 +70,7 @@ class CatalogCollectionsHandler(ApiHandler[StacContext]):
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog/collections/{collectionId}")
+@api.route(PATH_PREFIX + "/collections/{collectionId}")
 class CatalogCollectionHandler(ApiHandler[StacContext]):
     # noinspection PyPep8Naming
     @api.operation(operation_id="getCatalogCollection",
@@ -78,7 +83,7 @@ class CatalogCollectionHandler(ApiHandler[StacContext]):
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog/collections/{collectionId}/items")
+@api.route(PATH_PREFIX + "/collections/{collectionId}/items")
 class CatalogCollectionItemsHandler(ApiHandler[StacContext]):
     # noinspection PyPep8Naming
     @api.operation(operation_id="getCatalogCollectionItems",
@@ -87,15 +92,18 @@ class CatalogCollectionItemsHandler(ApiHandler[StacContext]):
         get_items_args = dict(
             ctx=self.ctx.datasets_ctx,
             base_url=self.request.reverse_base_url,
-            collection_id=collectionId
+            collection_id=collectionId,
         )
-        if 'limit' in self.request.query:
-            get_items_args['limit'] = \
-                self.request.get_query_arg('limit', type=int)
-        if 'cursor' in self.request.query:
-            get_items_args['cursor'] = \
-                self.request.get_query_arg('cursor', type=int)
-        result = get_collection_items(**get_items_args)
+        if collectionId == DEFAULT_COLLECTION_ID:
+            if 'limit' in self.request.query:
+                get_items_args['limit'] = \
+                    self.request.get_query_arg('limit', type=int)
+            if 'cursor' in self.request.query:
+                get_items_args['cursor'] = \
+                    self.request.get_query_arg('cursor', type=int)
+            result = get_datasets_collection_items(**get_items_args)
+        else:
+            result = get_single_collection_items(**get_items_args)
 
         return await self.response.finish(
             result, content_type='application/geo+json'
@@ -103,22 +111,27 @@ class CatalogCollectionItemsHandler(ApiHandler[StacContext]):
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog/collections/{collectionId}/items/{featureId}")
+@api.route(PATH_PREFIX + '/collections/{collectionId}/items/{featureId}')
 class CatalogCollectionItemHandler(ApiHandler[StacContext]):
     # noinspection PyPep8Naming
-    @api.operation(operation_id="getCatalogCollectionItem",
-                   summary="Get an item of a STAC catalog collection.")
+    @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)
+        result = get_collection_item(
+            self.ctx.datasets_ctx,
+            self.request.reverse_base_url,
+            collectionId,
+            featureId,
+        )
         return await self.response.finish(
             result, content_type='application/geo+json'
         )
 
 
 # noinspection PyAbstractClass,PyMethodMayBeStatic
-@api.route("/catalog/search")
+@api.route(PATH_PREFIX + "/search")
 class CatalogSearchHandler(ApiHandler[StacContext]):
 
     @api.operation(operation_id="searchCatalogByKeywords",
@@ -134,3 +147,18 @@ class CatalogSearchHandler(ApiHandler[StacContext]):
         # 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)
+
+
+# noinspection PyAbstractClass,PyMethodMayBeStatic
+@api.route(PATH_PREFIX + "/collections/{collectionId}/queryables")
+class QueryablesHandler(ApiHandler[StacContext]):
+    # noinspection PyPep8Naming
+    @api.operation(operation_id="searchCatalogByKeywords",
+                   summary='Return a JSON Schema defining the supported '
+                           'metadata filters (also called "queryables") '
+                           'for a specific collection.')
+    async def get(self, collectionId):
+        schema = get_collection_queryables(
+            ctx=self.ctx.datasets_ctx, collection_id=collectionId
+        )
+        return await self.response.finish(schema)


Test patch:
diff --git a/test/server/test_server.py b/test/server/test_server.py
index 72b9a9a7..e9915b64 100644
--- a/test/server/test_server.py
+++ b/test/server/test_server.py
@@ -19,6 +19,7 @@
 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 # DEALINGS IN THE SOFTWARE.
 
+import json
 import unittest
 from typing import Optional
 
@@ -135,112 +136,114 @@ class ServerTest(unittest.TestCase):
             },
             extension_registry=extension_registry
         )
-        self.assertEqual(
-            {
-                'type': 'object',
-                'additionalProperties': True,
-                'required': ['data_stores'],
-                'properties': {
-                    'api_spec': {
-                        'type': 'object',
-                        'title': 'API specification',
-                        'additionalProperties': False,
-                        'description': 'selected = (includes | ALL) - '
-                                       '(excludes | NONE)',
-                        'properties': {
-                            'excludes': {
-                                'type': 'array',
-                                'items': {
-                                    'type': 'string',
-                                    'minLength': 1,
-                                },
+        expected = {
+            'type': 'object',
+            'additionalProperties': True,
+            'required': ['data_stores'],
+            'properties': {
+                'api_spec': {
+                    'type': 'object',
+                    'title': 'API specification',
+                    'additionalProperties': False,
+                    'description': 'selected = (includes | ALL) - '
+                                   '(excludes | NONE)',
+                    'properties': {
+                        'excludes': {
+                            'type': 'array',
+                            'items': {
+                                'type': 'string',
+                                'minLength': 1,
                             },
-                            'includes': {
-                                'type': 'array',
-                                'items': {
-                                    'type': 'string',
-                                    'minLength': 1,
-                                },
-                            }
                         },
+                        'includes': {
+                            'type': 'array',
+                            'items': {
+                                'type': 'string',
+                                'minLength': 1,
+                            },
+                        }
                     },
-                    'base_dir': {
-                        'type': 'string',
-                        'title': 'Base directory used to resolve relative '
-                                 'local paths. Can be a local filesystem '
-                                 'path or an absolute URL.',
-                    },
-                    'address': {
-                        'type': 'string',
-                        'default': '0.0.0.0',
-                        'title': 'Server address.',
-                    },
-                    'port': {
-                        'type': 'integer',
-                        'title': 'Server port.',
-                        'default': 8080,
-                    },
-                    'url_prefix': {
-                        'type': 'string',
-                        'title': 'Prefix to be prepended to all URL '
-                                 'route paths. Can be an '
-                                 'absolute URL or a relative URL '
-                                 'path.',
-                    },
-                    'reverse_url_prefix': {
-                        'type': 'string',
-                        'title': 'Prefix to be prepended to '
-                                 'reverse URL paths returned by '
-                                 'server responses. Can be an '
-                                 'absolute URL or a relative URL '
-                                 'path.',
-                    },
-                    'static_routes': {
-                        'type': 'array',
-                        'title': 'Static content routes',
-                        'items': {
-                            'type': 'object',
-                            'required': ['path', 'dir_path'],
-                            'properties': {
-                                'path': {
-                                    'type': 'string',
-                                    'minLength': 1,
-                                    'title': 'The URL path',
-                                },
-                                'dir_path': {
-                                    'type': 'string',
-                                    'minLength': 1,
-                                    'title': 'A local directory path',
-                                },
-                                'default_filename': {
-                                    'type': 'string',
-                                    'examples': ['index.html'],
-                                    'minLength': 1,
-                                    'title': 'Optional default filename',
-                                },
-                                'openapi_metadata': {
-                                    'type': 'object',
-                                    'additionalProperties': True,
-                                    'title': 'Optional OpenAPI operation '
-                                             'metadata',
-                                },
+                },
+                'base_dir': {
+                    'type': 'string',
+                    'title': 'Base directory used to resolve relative '
+                             'local paths. Can be a local filesystem '
+                             'path or an absolute URL.',
+                },
+                'address': {
+                    'type': 'string',
+                    'default': '0.0.0.0',
+                    'title': 'Server address.',
+                },
+                'port': {
+                    'type': 'integer',
+                    'title': 'Server port.',
+                    'default': 8080,
+                },
+                'url_prefix': {
+                    'type': 'string',
+                    'title': 'Prefix to be prepended to all URL '
+                             'route paths. Can be an '
+                             'absolute URL or a relative URL '
+                             'path.',
+                },
+                'reverse_url_prefix': {
+                    'type': 'string',
+                    'title': 'Prefix to be prepended to '
+                             'reverse URL paths returned by '
+                             'server responses. Can be an '
+                             'absolute URL or a relative URL '
+                             'path.',
+                },
+                'static_routes': {
+                    'type': 'array',
+                    'title': 'Static content routes',
+                    'items': {
+                        'type': 'object',
+                        'required': ['path', 'dir_path'],
+                        'properties': {
+                            'path': {
+                                'type': 'string',
+                                'minLength': 1,
+                                'title': 'The URL path',
+                            },
+                            'dir_path': {
+                                'type': 'string',
+                                'minLength': 1,
+                                'title': 'A local directory path',
+                            },
+                            'default_filename': {
+                                'type': 'string',
+                                'examples': ['index.html'],
+                                'minLength': 1,
+                                'title': 'Optional default filename',
+                            },
+                            'openapi_metadata': {
+                                'type': 'object',
+                                'properties': {},
+                                'additionalProperties': True,
+                                'title': 'Optional OpenAPI operation '
+                                         'metadata',
                             },
-                            'additionalProperties': False,
                         },
+                        'additionalProperties': False,
                     },
-                    'trace_perf': {
-                        'type': 'boolean',
-                        'title': 'Output performance measures',
-                    },
-                    'data_stores': {
-                        'type': 'array',
-                        'items': {'additionalProperties': True,
-                                  'type': 'object'},
+                },
+                'trace_perf': {
+                    'type': 'boolean',
+                    'title': 'Output performance measures',
+                },
+                'data_stores': {
+                    'type': 'array',
+                    'items': {
+                        'properties': {},
+                        'additionalProperties': True,
+                        'type': 'object'
                     },
                 },
             },
-            server.config_schema.to_dict()
-        )
+        }
+        self.assertEqual(expected, server.config_schema.to_dict())
         self.assertIsInstance(server.config_schema, JsonObjectSchema)
 
     def test_config_schema_must_be_object(self):
diff --git a/test/webapi/ows/coverages/test_routes.py b/test/webapi/ows/coverages/test_routes.py
index bb573a1d..67169d6f 100644
--- a/test/webapi/ows/coverages/test_routes.py
+++ b/test/webapi/ows/coverages/test_routes.py
@@ -21,17 +21,19 @@
 
 from ...helpers import RoutesTestCase
 
+from xcube.webapi.ows.coverages.routes import PATH_PREFIX
+
 
 class CoveragesRoutesTest(RoutesTestCase):
     def test_fetch_coverage(self):
         response = self.fetch(
-            '/catalog/collections/demo/coverage?f=application/json'
+            PATH_PREFIX + '/collections/demo/coverage?f=application/json'
         )
         self.assertResponseOK(response)
 
     def test_fetch_coverage_html(self):
         response = self.fetch(
-            '/catalog/collections/demo/coverage',
+            PATH_PREFIX + '/collections/demo/coverage',
             headers=dict(
                 Accept='text/nonexistent,application/json;q=0.9,text/html;q=1.0'
             ),
@@ -41,23 +43,31 @@ class CoveragesRoutesTest(RoutesTestCase):
 
     def test_fetch_coverage_wrong_media_type(self):
         response = self.fetch(
-            '/catalog/collections/demo/coverage',
+            PATH_PREFIX + '/collections/demo/coverage',
             headers=dict(Accept='text/nonexistent'),
         )
         self.assertEqual(response.status, 415)
 
     def test_fetch_domainset(self):
-        response = self.fetch('/catalog/collections/demo/coverage/domainset')
+        response = self.fetch(
+            PATH_PREFIX + '/collections/demo/coverage/domainset'
+        )
         self.assertResponseOK(response)
 
     def test_fetch_rangetype(self):
-        response = self.fetch('/catalog/collections/demo/coverage/rangetype')
+        response = self.fetch(
+            PATH_PREFIX + '/collections/demo/coverage/rangetype'
+        )
         self.assertResponseOK(response)
 
     def test_fetch_metadata(self):
-        response = self.fetch('/catalog/collections/demo/coverage/metadata')
+        response = self.fetch(
+            PATH_PREFIX + '/collections/demo/coverage/metadata'
+        )
         self.assertResponseOK(response)
 
     def test_fetch_rangeset(self):
-        response = self.fetch('/catalog/collections/demo/coverage/rangeset')
+        response = self.fetch(
+            PATH_PREFIX + '/collections/demo/coverage/rangeset'
+        )
         self.assertEqual(response.status, 501)
diff --git a/test/webapi/ows/stac/demo-collection.json b/test/webapi/ows/stac/demo-collection.json
new file mode 100644
index 00000000..6144b5fb
--- /dev/null
+++ b/test/webapi/ows/stac/demo-collection.json
@@ -0,0 +1,743 @@
+{
+  "assets": {
+    "analytic": {
+      "title": "demo data access",
+      "roles": [
+        "data"
+      ],
+      "type": "application/zarr",
+      "href": "http://localhost:8080/s3/datasets/demo.zarr",
+      "xcube:analytic": {
+        "c2rcc_flags": {
+          "title": "c2rcc_flags data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo.zarr/c2rcc_flags"
+        },
+        "conc_chl": {
+          "title": "conc_chl data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo.zarr/conc_chl"
+        },
+        "conc_tsm": {
+          "title": "conc_tsm data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo.zarr/conc_tsm"
+        },
+        "kd489": {
+          "title": "kd489 data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo.zarr/kd489"
+        },
+        "quality_flags": {
+          "title": "quality_flags data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo.zarr/quality_flags"
+        }
+      }
+    },
+    "visual": {
+      "title": "demo visualisation",
+      "roles": [
+        "visual"
+      ],
+      "type": "image/png",
+      "href": "http://localhost:8080/tiles/demo/<variable>/{z}/{y}/{x}?time=<time>",
+      "xcube:visual": {
+        "c2rcc_flags": {
+          "title": "c2rcc_flags visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo/c2rcc_flags/{z}/{y}/{x}?time=<time>"
+        },
+        "conc_chl": {
+          "title": "conc_chl visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo/conc_chl/{z}/{y}/{x}?time=<time>"
+        },
+        "conc_tsm": {
+          "title": "conc_tsm visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo/conc_tsm/{z}/{y}/{x}?time=<time>"
+        },
+        "kd489": {
+          "title": "kd489 visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo/kd489/{z}/{y}/{x}?time=<time>"
+        },
+        "quality_flags": {
+          "title": "quality_flags visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo/quality_flags/{z}/{y}/{x}?time=<time>"
+        }
+      }
+    },
+    "thumbnail": {
+      "title": "demo thumbnail",
+      "roles": [
+        "thumbnail"
+      ],
+      "type": "image/png",
+      "href": "http://localhost:8080/tiles/demo/c2rcc_flags/0/0/0?time=2017-01-16T10:09:21.834255872"
+    }
+  },
+  "description": "demo",
+  "extent": {
+    "spatial": {
+      "bbox": [
+        0.0,
+        50.0,
+        5.0,
+        52.5
+      ]
+    },
+    "temporal": {
+      "interval": [
+        [
+          "2017-01-16T10:09:21Z",
+          "2017-01-30T10:46:33Z"
+        ]
+      ]
+    }
+  },
+  "id": "demo",
+  "keywords": [],
+  "license": "proprietary",
+  "links": [
+    {
+      "rel": "root",
+      "href": "http://localhost:8080/ogc",
+      "type": "application/json",
+      "title": "root of the OGC API and STAC catalog"
+    },
+    {
+      "rel": "self",
+      "type": "application/json",
+      "href": "http://localhost:8080/ogc/collections/demo",
+      "title": "this collection"
+    },
+    {
+      "rel": "parent",
+      "href": "http://localhost:8080/ogc/collections",
+      "title": "collections list"
+    },
+    {
+      "rel": "items",
+      "href": "http://localhost:8080/ogc/collections/demo/items",
+      "title": "feature collection of data cube items"
+    }
+  ],
+  "providers": [],
+  "stac_version": "1.0.0",
+  "summaries": {},
+  "title": "demo",
+  "type": "Collection",
+  "cube:dimensions": {
+    "lon": {
+      "type": "spatial",
+      "axis": "x",
+      "description": "longitude",
+      "unit": "degrees_east",
+      "extent": [
+        0,
+        5
+      ],
+      "step": 0.0025,
+      "reference_system": "EPSG:4326"
+    },
+    "lat": {
+      "type": "spatial",
+      "axis": "y",
+      "description": "latitude",
+      "unit": "degrees_north",
+      "extent": [
+        50,
+        52.5
+      ],
+      "step": 0.0025,
+      "reference_system": "EPSG:4326"
+    },
+    "time": {
+      "type": "temporal",
+      "description": "time",
+      "values": [
+        "2017-01-16T10:09:21Z",
+        "2017-01-25T09:35:51Z",
+        "2017-01-26T10:50:16Z",
+        "2017-01-28T09:58:11Z",
+        "2017-01-30T10:46:33Z"
+      ]
+    }
+  },
+  "cube:variables": {
+    "c2rcc_flags": {
+      "type": "data",
+      "dimensions": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "description": "C2RCC quality flags"
+    },
+    "conc_chl": {
+      "type": "data",
+      "dimensions": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "description": "Chlorophyll concentration",
+      "unit": "mg m^-3"
+    },
+    "conc_tsm": {
+      "type": "data",
+      "dimensions": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "description": "Total suspended matter dry weight concentration",
+      "unit": "g m^-3"
+    },
+    "kd489": {
+      "type": "data",
+      "dimensions": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "description": "Irradiance attenuation coefficient at 489 nm",
+      "unit": "m^-1"
+    },
+    "quality_flags": {
+      "type": "data",
+      "dimensions": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "description": "Classification and quality flags"
+    },
+    "lat_bnds": {
+      "type": "auxiliary",
+      "dimensions": [
+        "lat",
+        "bnds"
+      ],
+      "description": "latitude",
+      "unit": "degrees_north"
+    },
+    "lon_bnds": {
+      "type": "auxiliary",
+      "dimensions": [
+        "lon",
+        "bnds"
+      ],
+      "description": "longitude",
+      "unit": "degrees_east"
+    },
+    "time_bnds": {
+      "type": "auxiliary",
+      "dimensions": [
+        "time",
+        "bnds"
+      ],
+      "description": "time"
+    }
+  },
+  "xcube:dims": {
+    "lat": 1000,
+    "bnds": 2,
+    "lon": 2000,
+    "time": 5
+  },
+  "xcube:data_vars": [
+    {
+      "name": "c2rcc_flags",
+      "dtype": "float64",
+      "dims": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "chunks": [
+        [
+          1,
+          1,
+          1,
+          1,
+          1
+        ],
+        [
+          250,
+          250,
+          250,
+          250
+        ],
+        [
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250
+        ]
+      ],
+      "shape": [
+        5,
+        1000,
+        2000
+      ],
+      "attrs": {
+        "flag_coding_name": "c2rcc_flags",
+        "flag_descriptions": "The input spectrum to the atmospheric correction neural net was out of the scope of the training range and the inversion is likely to be wrong\tThe input spectrum to the atmospheric correction neural net out of training range\tOne of the inputs to the IOP retrieval neural net is out of training range\tHigh downwelling transmission is indicating cloudy conditions\tOne of the IOPs is out of range\tApig output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tAdet output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tAgelb output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tBpart output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tBwit output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tApig output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tAdet output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tAgelb output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tBpart output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tBwit output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tThe Rhow input spectrum to IOP neural net is probably not within the training range of the neural net, and the inversion is likely to be wrong.\tKd489 is out of range\tKdmin is out of range\tKdmin is at max\tKdmin is at max\tThe operators valid pixel expression has resolved to true",
+        "flag_masks": [
+          1,
+          2,
+          4,
+          8,
+          16,
+          32,
+          64,
+          128,
+          256,
+          512,
+          1024,
+          2048,
+          4096,
+          8192,
+          16384,
+          32768,
+          65536,
+          131072,
+          262144,
+          524288,
+          -2147483648
+        ],
+        "flag_meanings": "Rtosa_OOS Rtosa_OOR Rhow_OOR Cloud_risk Iop_OOR Apig_at_max Adet_at_max Agelb_at_max Bpart_at_max Bwit_at_max Apig_at_min Adet_at_min Agelb_at_min Bpart_at_min Bwit_at_min Rhow_OOS Kd489_OOR Kdmin_OOR Kd489_at_max Kdmin_at_max Valid_PE",
+        "long_name": "C2RCC quality flags"
+      }
+    },
+    {
+      "name": "conc_chl",
+      "dtype": "float64",
+      "dims": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "chunks": [
+        [
+          1,
+          1,
+          1,
+          1,
+          1
+        ],
+        [
+          250,
+          250,
+          250,
+          250
+        ],
+        [
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250
+        ]
+      ],
+      "shape": [
+        5,
+        1000,
+        2000
+      ],
+      "attrs": {
+        "color_table_blue_values": [
+          128,
+          255,
+          255,
+          255,
+          255,
+          0,
+          0,
+          0,
+          0,
+          0
+        ],
+        "color_table_green_values": [
+          0,
+          0,
+          102,
+          204,
+          255,
+          128,
+          255,
+          95,
+          0,
+          0
+        ],
+        "color_table_red_values": [
+          0,
+          0,
+          51,
+          0,
+          0,
+          0,
+          255,
+          255,
+          215,
+          150
+        ],
+        "color_table_sample_values": [
+          0.0,
+          0.5,
+          1.0,
+          2.0,
+          3.0,
+          4.5,
+          13.0,
+          25.0,
+          30.0,
+          40.0
+        ],
+        "long_name": "Chlorophyll concentration",
+        "units": "mg m^-3",
+        "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+      }
+    },
+    {
+      "name": "conc_tsm",
+      "dtype": "float64",
+      "dims": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "chunks": [
+        [
+          1,
+          1,
+          1,
+          1,
+          1
+        ],
+        [
+          250,
+          250,
+          250,
+          250
+        ],
+        [
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250
+        ]
+      ],
+      "shape": [
+        5,
+        1000,
+        2000
+      ],
+      "attrs": {
+        "long_name": "Total suspended matter dry weight concentration",
+        "units": "g m^-3",
+        "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+      }
+    },
+    {
+      "name": "kd489",
+      "dtype": "float64",
+      "dims": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "chunks": [
+        [
+          1,
+          1,
+          1,
+          1,
+          1
+        ],
+        [
+          250,
+          250,
+          250,
+          250
+        ],
+        [
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250
+        ]
+      ],
+      "shape": [
+        5,
+        1000,
+        2000
+      ],
+      "attrs": {
+        "long_name": "Irradiance attenuation coefficient at 489 nm",
+        "units": "m^-1",
+        "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+      }
+    },
+    {
+      "name": "quality_flags",
+      "dtype": "float64",
+      "dims": [
+        "time",
+        "lat",
+        "lon"
+      ],
+      "chunks": [
+        [
+          1,
+          1,
+          1,
+          1,
+          1
+        ],
+        [
+          250,
+          250,
+          250,
+          250
+        ],
+        [
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250,
+          250
+        ]
+      ],
+      "shape": [
+        5,
+        1000,
+        2000
+      ],
+      "attrs": {
+        "flag_coding_name": "quality_flags",
+        "flag_descriptions": "",
+        "flag_masks": [
+          -2147483648,
+          1073741824,
+          536870912,
+          268435456,
+          134217728,
+          67108864,
+          33554432,
+          16777216,
+          8388608,
+          4194304,
+          2097152,
+          1048576,
+          524288,
+          262144,
+          131072,
+          65536,
+          32768,
+          16384,
+          8192,
+          4096,
+          2048,
+          1024,
+          512,
+          256,
+          128,
+          64,
+          32,
+          16,
+          8,
+          4,
+          2,
+          1
+        ],
+        "flag_meanings": "land coastline fresh_inland_water tidal_region bright straylight_risk invalid cosmetic duplicated sun_glint_risk dubious saturated_Oa01 saturated_Oa02 saturated_Oa03 saturated_Oa04 saturated_Oa05 saturated_Oa06 saturated_Oa07 saturated_Oa08 saturated_Oa09 saturated_Oa10 saturated_Oa11 saturated_Oa12 saturated_Oa13 saturated_Oa14 saturated_Oa15 saturated_Oa16 saturated_Oa17 saturated_Oa18 saturated_Oa19 saturated_Oa20 saturated_Oa21",
+        "long_name": "Classification and quality flags"
+      }
+    }
+  ],
+  "xcube:coords": [
+    {
+      "name": "lat",
+      "dtype": "float64",
+      "dims": [
+        "lat"
+      ],
+      "chunks": null,
+      "shape": [
+        1000
+      ],
+      "attrs": {
+        "bounds": "lat_bnds",
+        "long_name": "latitude",
+        "standard_name": "latitude",
+        "units": "degrees_north"
+      }
+    },
+    {
+      "name": "lat_bnds",
+      "dtype": "float64",
+      "dims": [
+        "lat",
+        "bnds"
+      ],
+      "chunks": [
+        [
+          1000
+        ],
+        [
+          2
+        ]
+      ],
+      "shape": [
+        1000,
+        2
+      ],
+      "attrs": {
+        "long_name": "latitude",
+        "standard_name": "latitude",
+        "units": "degrees_north"
+      }
+    },
+    {
+      "name": "lon",
+      "dtype": "float64",
+      "dims": [
+        "lon"
+      ],
+      "chunks": null,
+      "shape": [
+        2000
+      ],
+      "attrs": {
+        "bounds": "lon_bnds",
+        "long_name": "longitude",
+        "standard_name": "longitude",
+        "units": "degrees_east"
+      }
+    },
+    {
+      "name": "lon_bnds",
+      "dtype": "float64",
+      "dims": [
+        "lon",
+        "bnds"
+      ],
+      "chunks": [
+        [
+          2000
+        ],
+        [
+          2
+        ]
+      ],
+      "shape": [
+        2000,
+        2
+      ],
+      "attrs": {
+        "long_name": "longitude",
+        "standard_name": "longitude",
+        "units": "degrees_east"
+      }
+    },
+    {
+      "name": "time",
+      "dtype": "datetime64[ns]",
+      "dims": [
+        "time"
+      ],
+      "chunks": null,
+      "shape": [
+        5
+      ],
+      "attrs": {
+        "bounds": "time_bnds",
+        "long_name": "time",
+        "standard_name": "time"
+      }
+    },
+    {
+      "name": "time_bnds",
+      "dtype": "datetime64[ns]",
+      "dims": [
+        "time",
+        "bnds"
+      ],
+      "chunks": [
+        [
+          5
+        ],
+        [
+          2
+        ]
+      ],
+      "shape": [
+        5,
+        2
+      ],
+      "attrs": {
+        "long_name": "time",
+        "standard_name": "time"
+      }
+    }
+  ],
+  "xcube:attrs": {
+    "Conventions": "CF-1.7"
+  },
+  "datetime": null,
+  "start_datetime": "2017-01-16T10:09:21Z",
+  "end_datetime": "2017-01-30T10:46:33Z"
+}
diff --git a/test/webapi/ows/stac/stac-item.json b/test/webapi/ows/stac/stac-datacubes-item.json
similarity index 98%
rename from test/webapi/ows/stac/stac-item.json
rename to test/webapi/ows/stac/stac-datacubes-item.json
index 4a257705..ac2c88bf 100644
--- a/test/webapi/ows/stac/stac-item.json
+++ b/test/webapi/ows/stac/stac-datacubes-item.json
@@ -945,21 +945,21 @@
   "links": [
     {
       "rel": "root",
-      "href": "http://localhost:8080/catalog",
+      "href": "http://localhost:8080/ogc",
       "type": "application/json",
-      "title": "root of the STAC catalog"
+      "title": "root of the OGC API and STAC catalog"
     },
     {
       "rel": "self",
-      "href": "http://localhost:8080/catalog/collections/datacubes/items/demo-1w"
+      "href": "http://localhost:8080/ogc/collections/datacubes/items/demo-1w"
     },
     {
       "rel": "collection",
-      "href": "http://localhost:8080/catalog/collections/datacubes"
+      "href": "http://localhost:8080/ogc/collections/datacubes"
     },
     {
       "rel": "parent",
-      "href": "http://localhost:8080/catalog/collections/datacubes"
+      "href": "http://localhost:8080/ogc/collections/datacubes"
     }
   ],
   "assets": {
diff --git a/test/webapi/ows/stac/stac-single-item.json b/test/webapi/ows/stac/stac-single-item.json
new file mode 100644
index 00000000..67392b1e
--- /dev/null
+++ b/test/webapi/ows/stac/stac-single-item.json
@@ -0,0 +1,1155 @@
+{
+  "stac_version": "1.0.0",
+  "stac_extensions": [
+    "https://stac-extensions.github.io/datacube/v2.1.0/schema.json"
+  ],
+  "type": "Feature",
+  "id": "demo-1w",
+  "bbox": [
+    0.0,
+    50.0,
+    5.0,
+    52.5
+  ],
+  "geometry": {
+    "type": "Polygon",
+    "coordinates": [
+      [
+        [
+          0.0,
+          50.0
+        ],
+        [
+          0.0,
+          52.5
+        ],
+        [
+          5.0,
+          52.5
+        ],
+        [
+          5.0,
+          50.0
+        ],
+        [
+          0.0,
+          50.0
+        ]
+      ]
+    ]
+  },
+  "properties": {
+    "cube:dimensions": {
+      "lon": {
+        "type": "spatial",
+        "axis": "x",
+        "description": "longitude",
+        "unit": "degrees_east",
+        "extent": [
+          0,
+          5
+        ],
+        "step": 0.0025,
+        "reference_system": "EPSG:4326"
+      },
+      "lat": {
+        "type": "spatial",
+        "axis": "y",
+        "description": "latitude",
+        "unit": "degrees_north",
+        "extent": [
+          50,
+          52.5
+        ],
+        "step": 0.0025,
+        "reference_system": "EPSG:4326"
+      },
+      "time": {
+        "type": "temporal",
+        "description": "time",
+        "values": [
+          "2017-01-22T00:00:00Z",
+          "2017-01-29T00:00:00Z",
+          "2017-02-05T00:00:00Z"
+        ]
+      }
+    },
+    "cube:variables": {
+      "c2rcc_flags": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "C2RCC quality flags"
+      },
+      "conc_chl": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Chlorophyll concentration",
+        "unit": "mg m^-3"
+      },
+      "conc_tsm": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Total suspended matter dry weight concentration",
+        "unit": "g m^-3"
+      },
+      "kd489": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Irradiance attenuation coefficient at 489 nm",
+        "unit": "m^-1"
+      },
+      "quality_flags": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Classification and quality flags"
+      },
+      "c2rcc_flags_stdev": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "C2RCC quality flags"
+      },
+      "conc_chl_stdev": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Chlorophyll concentration",
+        "unit": "mg m^-3"
+      },
+      "conc_tsm_stdev": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Total suspended matter dry weight concentration",
+        "unit": "g m^-3"
+      },
+      "kd489_stdev": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Irradiance attenuation coefficient at 489 nm",
+        "unit": "m^-1"
+      },
+      "quality_flags_stdev": {
+        "type": "data",
+        "dimensions": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "description": "Classification and quality flags"
+      },
+      "lat_bnds": {
+        "type": "auxiliary",
+        "dimensions": [
+          "lat",
+          "bnds"
+        ],
+        "description": "latitude",
+        "unit": "degrees_north"
+      },
+      "lon_bnds": {
+        "type": "auxiliary",
+        "dimensions": [
+          "lon",
+          "bnds"
+        ],
+        "description": "longitude",
+        "unit": "degrees_east"
+      }
+    },
+    "xcube:dims": {
+      "lat": 1000,
+      "bnds": 2,
+      "lon": 2000,
+      "time": 3
+    },
+    "xcube:data_vars": [
+      {
+        "name": "c2rcc_flags",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "flag_coding_name": "c2rcc_flags",
+          "flag_descriptions": "The input spectrum to the atmospheric correction neural net was out of the scope of the training range and the inversion is likely to be wrong\tThe input spectrum to the atmospheric correction neural net out of training range\tOne of the inputs to the IOP retrieval neural net is out of training range\tHigh downwelling transmission is indicating cloudy conditions\tOne of the IOPs is out of range\tApig output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tAdet output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tAgelb output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tBpart output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tBwit output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tApig output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tAdet output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tAgelb output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tBpart output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tBwit output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tThe Rhow input spectrum to IOP neural net is probably not within the training range of the neural net, and the inversion is likely to be wrong.\tKd489 is out of range\tKdmin is out of range\tKdmin is at max\tKdmin is at max\tThe operators valid pixel expression has resolved to true",
+          "flag_masks": [
+            1,
+            2,
+            4,
+            8,
+            16,
+            32,
+            64,
+            128,
+            256,
+            512,
+            1024,
+            2048,
+            4096,
+            8192,
+            16384,
+            32768,
+            65536,
+            131072,
+            262144,
+            524288,
+            -2147483648
+          ],
+          "flag_meanings": "Rtosa_OOS Rtosa_OOR Rhow_OOR Cloud_risk Iop_OOR Apig_at_max Adet_at_max Agelb_at_max Bpart_at_max Bwit_at_max Apig_at_min Adet_at_min Agelb_at_min Bpart_at_min Bwit_at_min Rhow_OOS Kd489_OOR Kdmin_OOR Kd489_at_max Kdmin_at_max Valid_PE",
+          "long_name": "C2RCC quality flags"
+        }
+      },
+      {
+        "name": "conc_chl",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "color_table_blue_values": [
+            128,
+            255,
+            255,
+            255,
+            255,
+            0,
+            0,
+            0,
+            0,
+            0
+          ],
+          "color_table_green_values": [
+            0,
+            0,
+            102,
+            204,
+            255,
+            128,
+            255,
+            95,
+            0,
+            0
+          ],
+          "color_table_red_values": [
+            0,
+            0,
+            51,
+            0,
+            0,
+            0,
+            255,
+            255,
+            215,
+            150
+          ],
+          "color_table_sample_values": [
+            0.0,
+            0.5,
+            1.0,
+            2.0,
+            3.0,
+            4.5,
+            13.0,
+            25.0,
+            30.0,
+            40.0
+          ],
+          "long_name": "Chlorophyll concentration",
+          "units": "mg m^-3",
+          "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+        }
+      },
+      {
+        "name": "conc_tsm",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "long_name": "Total suspended matter dry weight concentration",
+          "units": "g m^-3",
+          "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+        }
+      },
+      {
+        "name": "kd489",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "long_name": "Irradiance attenuation coefficient at 489 nm",
+          "units": "m^-1",
+          "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+        }
+      },
+      {
+        "name": "quality_flags",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "flag_coding_name": "quality_flags",
+          "flag_descriptions": "",
+          "flag_masks": [
+            -2147483648,
+            1073741824,
+            536870912,
+            268435456,
+            134217728,
+            67108864,
+            33554432,
+            16777216,
+            8388608,
+            4194304,
+            2097152,
+            1048576,
+            524288,
+            262144,
+            131072,
+            65536,
+            32768,
+            16384,
+            8192,
+            4096,
+            2048,
+            1024,
+            512,
+            256,
+            128,
+            64,
+            32,
+            16,
+            8,
+            4,
+            2,
+            1
+          ],
+          "flag_meanings": "land coastline fresh_inland_water tidal_region bright straylight_risk invalid cosmetic duplicated sun_glint_risk dubious saturated_Oa01 saturated_Oa02 saturated_Oa03 saturated_Oa04 saturated_Oa05 saturated_Oa06 saturated_Oa07 saturated_Oa08 saturated_Oa09 saturated_Oa10 saturated_Oa11 saturated_Oa12 saturated_Oa13 saturated_Oa14 saturated_Oa15 saturated_Oa16 saturated_Oa17 saturated_Oa18 saturated_Oa19 saturated_Oa20 saturated_Oa21",
+          "long_name": "Classification and quality flags"
+        }
+      },
+      {
+        "name": "c2rcc_flags_stdev",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "flag_coding_name": "c2rcc_flags",
+          "flag_descriptions": "The input spectrum to the atmospheric correction neural net was out of the scope of the training range and the inversion is likely to be wrong\tThe input spectrum to the atmospheric correction neural net out of training range\tOne of the inputs to the IOP retrieval neural net is out of training range\tHigh downwelling transmission is indicating cloudy conditions\tOne of the IOPs is out of range\tApig output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tAdet output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tAgelb output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tBpart output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tBwit output of the IOP retrieval neural net is at its maximum. This means that the true value is this value or higher.\tApig output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tAdet output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tAgelb output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tBpart output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tBwit output of the IOP retrieval neural net is at its minimum. This means that the true value is this value or lower.\tThe Rhow input spectrum to IOP neural net is probably not within the training range of the neural net, and the inversion is likely to be wrong.\tKd489 is out of range\tKdmin is out of range\tKdmin is at max\tKdmin is at max\tThe operators valid pixel expression has resolved to true",
+          "flag_masks": [
+            1,
+            2,
+            4,
+            8,
+            16,
+            32,
+            64,
+            128,
+            256,
+            512,
+            1024,
+            2048,
+            4096,
+            8192,
+            16384,
+            32768,
+            65536,
+            131072,
+            262144,
+            524288,
+            -2147483648
+          ],
+          "flag_meanings": "Rtosa_OOS Rtosa_OOR Rhow_OOR Cloud_risk Iop_OOR Apig_at_max Adet_at_max Agelb_at_max Bpart_at_max Bwit_at_max Apig_at_min Adet_at_min Agelb_at_min Bpart_at_min Bwit_at_min Rhow_OOS Kd489_OOR Kdmin_OOR Kd489_at_max Kdmin_at_max Valid_PE",
+          "long_name": "C2RCC quality flags"
+        }
+      },
+      {
+        "name": "conc_chl_stdev",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "color_table_blue_values": [
+            128,
+            255,
+            255,
+            255,
+            255,
+            0,
+            0,
+            0,
+            0,
+            0
+          ],
+          "color_table_green_values": [
+            0,
+            0,
+            102,
+            204,
+            255,
+            128,
+            255,
+            95,
+            0,
+            0
+          ],
+          "color_table_red_values": [
+            0,
+            0,
+            51,
+            0,
+            0,
+            0,
+            255,
+            255,
+            215,
+            150
+          ],
+          "color_table_sample_values": [
+            0.0,
+            0.5,
+            1.0,
+            2.0,
+            3.0,
+            4.5,
+            13.0,
+            25.0,
+            30.0,
+            40.0
+          ],
+          "long_name": "Chlorophyll concentration",
+          "units": "mg m^-3",
+          "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+        }
+      },
+      {
+        "name": "conc_tsm_stdev",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "long_name": "Total suspended matter dry weight concentration",
+          "units": "g m^-3",
+          "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+        }
+      },
+      {
+        "name": "kd489_stdev",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "long_name": "Irradiance attenuation coefficient at 489 nm",
+          "units": "m^-1",
+          "valid_pixel_expression": "c2rcc_flags.Valid_PE"
+        }
+      },
+      {
+        "name": "quality_flags_stdev",
+        "dtype": "float64",
+        "dims": [
+          "time",
+          "lat",
+          "lon"
+        ],
+        "chunks": [
+          [
+            1,
+            1,
+            1
+          ],
+          [
+            250,
+            250,
+            250,
+            250
+          ],
+          [
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250,
+            250
+          ]
+        ],
+        "shape": [
+          3,
+          1000,
+          2000
+        ],
+        "attrs": {
+          "flag_coding_name": "quality_flags",
+          "flag_descriptions": "",
+          "flag_masks": [
+            -2147483648,
+            1073741824,
+            536870912,
+            268435456,
+            134217728,
+            67108864,
+            33554432,
+            16777216,
+            8388608,
+            4194304,
+            2097152,
+            1048576,
+            524288,
+            262144,
+            131072,
+            65536,
+            32768,
+            16384,
+            8192,
+            4096,
+            2048,
+            1024,
+            512,
+            256,
+            128,
+            64,
+            32,
+            16,
+            8,
+            4,
+            2,
+            1
+          ],
+          "flag_meanings": "land coastline fresh_inland_water tidal_region bright straylight_risk invalid cosmetic duplicated sun_glint_risk dubious saturated_Oa01 saturated_Oa02 saturated_Oa03 saturated_Oa04 saturated_Oa05 saturated_Oa06 saturated_Oa07 saturated_Oa08 saturated_Oa09 saturated_Oa10 saturated_Oa11 saturated_Oa12 saturated_Oa13 saturated_Oa14 saturated_Oa15 saturated_Oa16 saturated_Oa17 saturated_Oa18 saturated_Oa19 saturated_Oa20 saturated_Oa21",
+          "long_name": "Classification and quality flags"
+        }
+      }
+    ],
+    "xcube:coords": [
+      {
+        "name": "lat",
+        "dtype": "float64",
+        "dims": [
+          "lat"
+        ],
+        "chunks": null,
+        "shape": [
+          1000
+        ],
+        "attrs": {
+          "bounds": "lat_bnds",
+          "long_name": "latitude",
+          "standard_name": "latitude",
+          "units": "degrees_north"
+        }
+      },
+      {
+        "name": "lat_bnds",
+        "dtype": "float64",
+        "dims": [
+          "lat",
+          "bnds"
+        ],
+        "chunks": [
+          [
+            1000
+          ],
+          [
+            2
+          ]
+        ],
+        "shape": [
+          1000,
+          2
+        ],
+        "attrs": {
+          "long_name": "latitude",
+          "standard_name": "latitude",
+          "units": "degrees_north"
+        }
+      },
+      {
+        "name": "lon",
+        "dtype": "float64",
+        "dims": [
+          "lon"
+        ],
+        "chunks": null,
+        "shape": [
+          2000
+        ],
+        "attrs": {
+          "bounds": "lon_bnds",
+          "long_name": "longitude",
+          "standard_name": "longitude",
+          "units": "degrees_east"
+        }
+      },
+      {
+        "name": "lon_bnds",
+        "dtype": "float64",
+        "dims": [
+          "lon",
+          "bnds"
+        ],
+        "chunks": [
+          [
+            2000
+          ],
+          [
+            2
+          ]
+        ],
+        "shape": [
+          2000,
+          2
+        ],
+        "attrs": {
+          "long_name": "longitude",
+          "standard_name": "longitude",
+          "units": "degrees_east"
+        }
+      },
+      {
+        "name": "time",
+        "dtype": "datetime64[ns]",
+        "dims": [
+          "time"
+        ],
+        "chunks": null,
+        "shape": [
+          3
+        ],
+        "attrs": {
+          "bounds": "time_bnds",
+          "long_name": "time",
+          "standard_name": "time"
+        }
+      }
+    ],
+    "xcube:attrs": {
+      "Conventions": "CF-1.7"
+    },
+    "datetime": null,
+    "start_datetime": "2017-01-22T00:00:00Z",
+    "end_datetime": "2017-02-05T00:00:00Z"
+  },
+  "collection": "demo-1w",
+  "links": [
+    {
+      "rel": "root",
+      "href": "http://localhost:8080/ogc",
+      "type": "application/json",
+      "title": "root of the OGC API and STAC catalog"
+    },
+    {
+      "rel": "self",
+      "href": "http://localhost:8080/ogc/collections/demo-1w/items/datacube"
+    },
+    {
+      "rel": "collection",
+      "href": "http://localhost:8080/ogc/collections/demo-1w"
+    },
+    {
+      "rel": "parent",
+      "href": "http://localhost:8080/ogc/collections/demo-1w"
+    }
+  ],
+  "assets": {
+    "analytic": {
+      "title": "demo-1w data access",
+      "roles": [
+        "data"
+      ],
+      "type": "application/zarr",
+      "href": "http://localhost:8080/s3/datasets/demo-1w.zarr",
+      "xcube:analytic": {
+        "c2rcc_flags": {
+          "title": "c2rcc_flags data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/c2rcc_flags"
+        },
+        "conc_chl": {
+          "title": "conc_chl data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/conc_chl"
+        },
+        "conc_tsm": {
+          "title": "conc_tsm data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/conc_tsm"
+        },
+        "kd489": {
+          "title": "kd489 data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/kd489"
+        },
+        "quality_flags": {
+          "title": "quality_flags data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/quality_flags"
+        },
+        "c2rcc_flags_stdev": {
+          "title": "c2rcc_flags_stdev data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/c2rcc_flags_stdev"
+        },
+        "conc_chl_stdev": {
+          "title": "conc_chl_stdev data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/conc_chl_stdev"
+        },
+        "conc_tsm_stdev": {
+          "title": "conc_tsm_stdev data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/conc_tsm_stdev"
+        },
+        "kd489_stdev": {
+          "title": "kd489_stdev data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/kd489_stdev"
+        },
+        "quality_flags_stdev": {
+          "title": "quality_flags_stdev data access",
+          "roles": [
+            "data"
+          ],
+          "type": "application/zarr",
+          "href": "http://localhost:8080/s3/datasets/demo-1w.zarr/quality_flags_stdev"
+        }
+      }
+    },
+    "visual": {
+      "title": "demo-1w visualisation",
+      "roles": [
+        "visual"
+      ],
+      "type": "image/png",
+      "href": "http://localhost:8080/tiles/demo-1w/<variable>/{z}/{y}/{x}?time=<time>",
+      "xcube:visual": {
+        "c2rcc_flags": {
+          "title": "c2rcc_flags visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/c2rcc_flags/{z}/{y}/{x}?time=<time>"
+        },
+        "conc_chl": {
+          "title": "conc_chl visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/conc_chl/{z}/{y}/{x}?time=<time>"
+        },
+        "conc_tsm": {
+          "title": "conc_tsm visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/conc_tsm/{z}/{y}/{x}?time=<time>"
+        },
+        "kd489": {
+          "title": "kd489 visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/kd489/{z}/{y}/{x}?time=<time>"
+        },
+        "quality_flags": {
+          "title": "quality_flags visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/quality_flags/{z}/{y}/{x}?time=<time>"
+        },
+        "c2rcc_flags_stdev": {
+          "title": "c2rcc_flags_stdev visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/c2rcc_flags_stdev/{z}/{y}/{x}?time=<time>"
+        },
+        "conc_chl_stdev": {
+          "title": "conc_chl_stdev visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/conc_chl_stdev/{z}/{y}/{x}?time=<time>"
+        },
+        "conc_tsm_stdev": {
+          "title": "conc_tsm_stdev visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/conc_tsm_stdev/{z}/{y}/{x}?time=<time>"
+        },
+        "kd489_stdev": {
+          "title": "kd489_stdev visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/kd489_stdev/{z}/{y}/{x}?time=<time>"
+        },
+        "quality_flags_stdev": {
+          "title": "quality_flags_stdev visualisation",
+          "roles": [
+            "visual"
+          ],
+          "type": "image/png",
+          "href": "http://localhost:8080/tiles/demo-1w/quality_flags_stdev/{z}/{y}/{x}?time=<time>"
+        }
+      }
+    },
+    "thumbnail": {
+      "title": "demo-1w thumbnail",
+      "roles": [
+        "thumbnail"
+      ],
+      "type": "image/png",
+      "href": "http://localhost:8080/tiles/demo-1w/c2rcc_flags/0/0/0?time=2017-01-22T00:00:00.000000000"
+    }
+  }
+}
\ No newline at end of file
diff --git a/test/webapi/ows/stac/test_controllers.py b/test/webapi/ows/stac/test_controllers.py
index 65f64ba9..8bcd8f7e 100644
--- a/test/webapi/ows/stac/test_controllers.py
+++ b/test/webapi/ows/stac/test_controllers.py
@@ -18,64 +18,117 @@
 # 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
 import json
-import os.path
 import unittest
 from pathlib import Path
+import functools
 
-from xcube.webapi.ows.stac.config import DEFAULT_CATALOG_DESCRIPTION
+import xarray as xr
+import xcube
+from xcube.core.gridmapping import GridMapping
+from xcube.core.new import new_cube
+from xcube.server.api import ApiError
+from xcube.webapi.ows.stac.config import DEFAULT_CATALOG_DESCRIPTION, \
+    DEFAULT_FEATURE_ID
 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 (
+    STAC_VERSION,
+    get_collection_queryables,
+    get_datacube_dimensions,
+    get_single_collection_items,
+)
 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_datasets_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
 
+PATH_PREFIX = '/ogc'
+
 BASE_URL = "http://localhost:8080"
 
 _OGC_PREFIX = 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf'
 _STAC_PREFIX = 'https://api.stacspec.org'
 
-EXPECTED_CONFORMANCE = [
-    f'{_STAC_PREFIX}/v1.0.0-rc.2/core',
-    f'{_STAC_PREFIX}/v1.0.0-rc.2/ogcapi-features',
-    f'{_STAC_PREFIX}/v1.0.0-rc.1/collections',
-    f'{_OGC_PREFIX}/core',
-    f'{_OGC_PREFIX}/oas30',
-    f'{_OGC_PREFIX}/html',
-    f'{_OGC_PREFIX}/geojson'
+EXPECTED_CONFORMANCE = {
+    'https://api.geodatacube.example/1.0.0-beta',
+    'https://api.stacspec.org/v1.0.0/core',
+    'https://api.stacspec.org/v1.0.0/collections',
+    'https://api.stacspec.org/v1.0.0/ogcapi-features',
+    'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core',
+    'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json',
+    'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30',
+    'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections',
+    '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',
+    'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/geodata-coverage',
+    'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/cisjson',
+    'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-subset',
+    'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/oas30'
+}
+
+EXPECTED_ENDPOINTS = [
+    {'methods': methods, 'path': path}
+    for methods, path in [
+        (['get'], '/ogc/collections/{collectionId}/coverage'),
+        (['get'], '/ogc/collections/{collectionId}/coverage/domainset'),
+        (['get'], '/ogc/collections/{collectionId}/coverage/rangetype'),
+        (['get'], '/ogc/collections/{collectionId}/coverage/metadata'),
+        (['get'], '/ogc/collections/{collectionId}/coverage/rangeset'),
+        (['get'], '/ogc'),
+        (['get'], '/ogc/conformance'),
+        (['get'], '/ogc/collections'),
+        (['get'], '/ogc/collections/{collectionId}'),
+        (['get'], '/ogc/collections/{collectionId}/items'),
+        (['get'], '/ogc/collections/{collectionId}/items/{featureId}'),
+        (['get', 'post'], '/ogc/search'),
+        (['get'], '/ogc/collections/{collectionId}/queryables'),
+    ]
 ]
 
-EXPECTED_COLLECTION = {
+EXPECTED_DATASETS_COLLECTION = {
     'description': DEFAULT_COLLECTION_DESCRIPTION,
-    'extent': {'spatial': {'bbox': [[-180.0, -90.0, 180.0, 90.0]]},
-               'temporal': {'interval': [['2000-01-01T00:00:00Z', None]]}},
+    'extent': {
+        'spatial': {'bbox': [[-180.0, -90.0, 180.0, 90.0]]},
+        'temporal': {'interval': [['2000-01-01T00:00:00Z', None]]},
+    },
     'id': DEFAULT_COLLECTION_ID,
     'keywords': [],
     'license': 'proprietary',
     'links': [
-        {'href': f'{BASE_URL}/catalog',
-         'rel': 'root',
-         'title': 'root of the STAC catalog',
-         'type': 'application/json'},
-        {'href': f'{BASE_URL}/catalog/collections/{DEFAULT_COLLECTION_ID}',
-         'rel': 'self',
-         'title': 'this collection',
-         'type': 'application/json'},
-        {'href': f'{BASE_URL}/catalog/collections',
-         'rel': 'parent',
-         'title': 'collections list'},
         {
-            'href': f'{BASE_URL}/catalog/collections/{DEFAULT_COLLECTION_ID}/items',
+            'href': f'{BASE_URL}{PATH_PREFIX}',
+            'rel': 'root',
+            'title': 'root of the OGC API and STAC catalog',
+            'type': 'application/json',
+        },
+        {
+            'href': f'{BASE_URL}{PATH_PREFIX}/collections/'
+                    f'{DEFAULT_COLLECTION_ID}',
+            'rel': 'self',
+            'title': 'this collection',
+            'type': 'application/json',
+        },
+        {
+            'href': f'{BASE_URL}{PATH_PREFIX}/collections',
+            'rel': 'parent',
+            'title': 'collections list',
+        },
+        {
+            'href': f'{BASE_URL}{PATH_PREFIX}/collections/'
+                    f'{DEFAULT_COLLECTION_ID}/items',
             'rel': 'items',
-            'title': 'feature collection of data cube items'}
+            'title': 'feature collection of data cube items',
+        },
     ],
     'providers': [],
     'stac_version': STAC_VERSION,
@@ -89,21 +142,60 @@ EXPECTED_COLLECTION = {
 
 
 class StacControllersTest(unittest.TestCase):
-    def test_get_collection_item(self):
+
+    @functools.lru_cache
+    def read_json(self, filename):
+        with open(Path(__file__).parent / filename, mode='r') as fp:
+            content = json.load(fp)
+        return content
+
+    # Commented out to keep coverage checkers happy.
+    # @staticmethod
+    # def write_json(content, filename):
+    #     """Convenience function for updating saved expected JSON
+    #
+    #     Not used during an ordinary test run.
+    #     """
+    #     with open(Path(__file__).parent / filename, mode='w') as fp:
+    #         json.dump(content, fp, indent=2)
+
+    def test_get_datasets_collection_item(self):
         self.maxDiff = None
-        result = get_collection_item(get_stac_ctx().datasets_ctx, BASE_URL,
-                                     DEFAULT_COLLECTION_ID, "demo-1w")
+        result = get_collection_item(
+            get_stac_ctx().datasets_ctx,
+            BASE_URL,
+            DEFAULT_COLLECTION_ID,
+            "demo-1w",
+        )
         self.assertIsInstance(result, dict)
-        path = Path(__file__).parent / "stac-item.json"
-        # with open(path, mode="w") as fp:
-        #     json.dump(result, fp, indent=2)
-        with open(path, mode="r") as fp:
-            expected_result = json.load(fp)
-        self.assertEqual(expected_result, result)
-
-    def test_get_collection_items(self):
-        result = get_collection_items(get_stac_ctx().datasets_ctx, BASE_URL,
-                                      DEFAULT_COLLECTION_ID)
+        self.assertEqual(self.read_json('stac-datacubes-item.json'), result)
+
+    def test_get_single_collection_item(self):
+        self.maxDiff = None
+        result = get_collection_item(
+            get_stac_ctx().datasets_ctx,
+            BASE_URL,
+            "demo-1w",
+            DEFAULT_FEATURE_ID
+        )
+        self.assertIsInstance(result, dict)
+        self.assertEqual(self.read_json('stac-single-item.json'), result)
+
+    def test_get_collection_item_nonexistent_feature(self):
+        self.assertRaises(
+            ApiError.NotFound,
+            get_collection_item,
+                get_stac_ctx().datasets_ctx,
+                BASE_URL,
+                'demo-1w',
+                'this-feature-does-not-exist'
+
+        )
+
+    def test_get_datasets_collection_items(self):
+        result = get_datasets_collection_items(
+            get_stac_ctx().datasets_ctx, BASE_URL, DEFAULT_COLLECTION_ID
+        )
         self.assertIsInstance(result, dict)
         self.assertIn('features', result)
 
@@ -119,89 +211,196 @@ class StacControllersTest(unittest.TestCase):
             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_single_collection_items(self):
+        result = get_single_collection_items(
+            get_stac_ctx().datasets_ctx,
+            BASE_URL,
+            "demo-1w",
+        )
+        expected_item = self.read_json('stac-single-item.json')
+        self.assertEqual('FeatureCollection', result['type'])
+        self.assertEqual(
+            {'root', 'self'},
+            set([link['rel'] for link in result['links']])
+        )
+        self.assertLess(
+            datetime.datetime.now().astimezone()
+            - datetime.datetime.fromisoformat(result['timeStamp']),
+            datetime.timedelta(seconds=10)
+        )
+        for key in 'bbox', 'geometry', 'properties', 'assets':
+            self.assertEqual(
+                expected_item[key],
+                result['features'][0][key]
+            )
+
+    def test_get_datasets_collection(self):
+        result = get_collection(
+            get_stac_ctx().datasets_ctx, BASE_URL, DEFAULT_COLLECTION_ID
+        )
+        self.assertEqual(EXPECTED_DATASETS_COLLECTION, result)
+
+    def test_get_single_collection(self):
+        result = get_collection(
+            get_stac_ctx().datasets_ctx, BASE_URL, 'demo'
+        )
+        self.assertEqual(self.read_json('demo-collection.json'), result)
 
     def test_get_collections(self):
         result = get_collections(get_stac_ctx().datasets_ctx, BASE_URL)
         self.assertEqual(
-            {'collections': [EXPECTED_COLLECTION],
-             'links': [{'href': f'{BASE_URL}/catalog',
-                        'rel': 'root',
-                        'title': 'root of the STAC catalog',
-                        'type': 'application/json'},
-                       {'href': f'{BASE_URL}/catalog/collections',
-                        'rel': 'self',
-                        'type': 'application/json'},
-                       {'href': f'{BASE_URL}/catalog', 'rel': 'parent'}]},
-            result
+            [
+                {
+                    'href': f'{BASE_URL}{PATH_PREFIX}',
+                    'rel': 'root',
+                    'title': 'root of the OGC API and STAC catalog',
+                    'type': 'application/json',
+                },
+                {
+                    'href': f'{BASE_URL}{PATH_PREFIX}/collections',
+                    'rel': 'self',
+                    'type': 'application/json',
+                },
+                {'href': f'{BASE_URL}{PATH_PREFIX}', 'rel': 'parent'},
+            ],
+            result['links']
         )
-
-    def test_get_conformance(self):
-        result = get_conformance(get_stac_ctx().datasets_ctx)
+        self.assertEqual(EXPECTED_DATASETS_COLLECTION, result['collections'][0])
         self.assertEqual(
-            {
-                'conformsTo': EXPECTED_CONFORMANCE
-            },
-            result
+            ['datacubes', 'demo', 'demo-1w'],
+            [collection['id'] for collection in result['collections']]
         )
 
+    def test_get_conformance(self):
+        result = get_conformance()
+        self.assertEqual(EXPECTED_CONFORMANCE, set(result.get('conformsTo')))
+
     def test_get_root(self):
         result = get_root(get_stac_ctx().datasets_ctx, BASE_URL)
+        # Handle conformance separately, since we don't care about the order.
+        conformance = result.pop('conformsTo')
+        self.assertEqual(EXPECTED_CONFORMANCE, set(conformance))
         self.assertEqual(
             {
-                'conformsTo': EXPECTED_CONFORMANCE,
                 'description': DEFAULT_CATALOG_DESCRIPTION,
                 'id': DEFAULT_CATALOG_ID,
                 'links': [
-                    {'href': f'{BASE_URL}/catalog',
-                     'rel': 'root',
-                     'title': 'root of the STAC catalog',
-                     'type': 'application/json'},
-                    {'href': f'{BASE_URL}/catalog',
-                     'rel': 'self',
-                     'title': 'this document',
-                     'type': 'application/json'},
-                    {'href': f'{BASE_URL}/openapi.json',
-                     'rel': 'service-desc',
-                     'title': 'the API definition',
-                     'type': 'application/vnd.oai.openapi+json;version=3.0'},
-                    {'href': f'{BASE_URL}/openapi.html',
-                     'rel': 'service-doc',
-                     'title': 'the API documentation',
-                     'type': 'text/html'},
-                    {'href': f'{BASE_URL}/catalog/conformance',
-                     'rel': 'conformance',
-                     'title': 'OGC API conformance classes'
-                              ' implemented by this server',
-                     'type': 'application/json'},
-                    {'href': f'{BASE_URL}/catalog/collections',
-                     'rel': 'data',
-                     'title': 'Information about the feature collections',
-                     'type': 'application/json'},
-                    {'href': f'{BASE_URL}/catalog/search',
-                     'rel': 'search',
-                     'title': 'Search across feature collections',
-                     'type': 'application/json'},
                     {
-                        'href': f'{BASE_URL}/catalog/collections/'
-                                f'{DEFAULT_COLLECTION_ID}',
+                        'href': f'{BASE_URL}{PATH_PREFIX}',
+                        'rel': 'root',
+                        'title': 'root of the OGC API and STAC catalog',
+                        'type': 'application/json',
+                    },
+                    {
+                        'href': f'{BASE_URL}{PATH_PREFIX}',
+                        'rel': 'self',
+                        'title': 'this document',
+                        'type': 'application/json',
+                    },
+                    {
+                        'href': f'{BASE_URL}/openapi.json',
+                        'rel': 'service-desc',
+                        'title': 'the API definition',
+                        'type': 'application/vnd.oai.openapi+json;version=3.0',
+                    },
+                    {
+                        'href': f'{BASE_URL}/openapi.html',
+                        'rel': 'service-doc',
+                        'title': 'the API documentation',
+                        'type': 'text/html',
+                    },
+                    {
+                        'href': f'{BASE_URL}{PATH_PREFIX}/conformance',
+                        'rel': 'conformance',
+                        'title': 'OGC API conformance classes'
+                        ' implemented by this server',
+                        'type': 'application/json',
+                    },
+                    {
+                        'href': f'{BASE_URL}{PATH_PREFIX}/collections',
+                        'rel': 'data',
+                        'title': 'Information about the feature collections',
+                        'type': 'application/json',
+                    },
+                    {
+                        'href': f'{BASE_URL}{PATH_PREFIX}/search',
+                        'rel': 'search',
+                        'title': 'Search across feature collections',
+                        'type': 'application/json',
+                    },
+                    {
+                        'href': f'{BASE_URL}{PATH_PREFIX}/collections/'
+                        f'{DEFAULT_COLLECTION_ID}',
                         'rel': 'child',
                         'title': DEFAULT_COLLECTION_DESCRIPTION,
-                        'type': 'application/json'}
+                        'type': 'application/json',
+                    },
                 ],
                 'stac_version': STAC_VERSION,
                 'title': DEFAULT_CATALOG_TITLE,
-                'type': 'Catalog'
+                'type': 'Catalog',
+                'api_version': '1.0.0',
+                'backend_version': xcube.__version__,
+                'gdc_version': '1.0.0-beta.1',
+                'endpoints': EXPECTED_ENDPOINTS,
             },
-            result
+            result,
         )
+
+    def test_get_collection_queryables(self):
+        result = get_collection_queryables(
+            get_stac_ctx().datasets_ctx, DEFAULT_COLLECTION_ID
+        )
+        self.assertEqual(
+            {'additionalProperties': False,
+             'properties': {},
+             'title': 'datacubes',
+             'type': 'object'},
+            result)
+
+    def test_get_datacube_dimensions(self):
+        dim_name = 'a_new_dimension'
+        cube = new_cube(variables={'v': 0}).expand_dims({dim_name: 1})
+        cube[dim_name] = xr.DataArray([0])
+        dims = get_datacube_dimensions(cube, GridMapping.from_dataset(cube))
+
+        expected = {
+            'lon': {
+                'type': 'spatial',
+                'axis': 'x',
+                'description': 'longitude',
+                'unit': 'degrees_east',
+                'extent': [-180, 180],
+                'step': 1,
+                'reference_system': 'EPSG:4326',
+            },
+            'lat': {
+                'type': 'spatial',
+                'axis': 'y',
+                'description': 'latitude',
+                'unit': 'degrees_north',
+                'extent': [-90, 90],
+                'step': 1,
+                'reference_system': 'EPSG:4326',
+            },
+            'time': {
+                'type': 'temporal',
+                'values': [
+                    '2010-01-01T12:00:00Z',
+                    '2010-01-02T12:00:00Z',
+                    '2010-01-03T12:00:00Z',
+                    '2010-01-04T12:00:00Z',
+                    '2010-01-05T12:00:00Z',
+                ],
+            },
+            dim_name: {
+                'type': 'unknown', 'range': [0, 0], 'values': [0]
+            }
+        }
+        self.assertEqual(expected, dims)
diff --git a/test/webapi/ows/stac/test_routes.py b/test/webapi/ows/stac/test_routes.py
index b0ee89f9..e148f8c5 100644
--- a/test/webapi/ows/stac/test_routes.py
+++ b/test/webapi/ows/stac/test_routes.py
@@ -21,53 +21,69 @@
 
 from typing import Any, Mapping
 
+from xcube.webapi.ows.stac.config import DEFAULT_COLLECTION_ID
 from ...helpers import RoutesTestCase, get_res_test_dir
 
+from xcube.webapi.ows.stac.routes import PATH_PREFIX
+
 
 class StacRoutesTest(RoutesTestCase):
     """STAC endpoints smoke tests"""
 
     def test_fetch_catalog(self):
-        response = self.fetch('/catalog')
+        response = self.fetch(PATH_PREFIX + '')
         self.assertResponseOK(response)
 
     def test_fetch_catalog_conformance(self):
-        response = self.fetch('/catalog/conformance')
+        response = self.fetch(PATH_PREFIX + '/conformance')
         self.assertResponseOK(response)
 
     def test_fetch_catalog_collections(self):
-        response = self.fetch('/catalog/collections')
+        response = self.fetch(PATH_PREFIX + '/collections')
         self.assertResponseOK(response)
 
     def test_fetch_catalog_collection(self):
-        response = self.fetch('/catalog/collections/datasets')
+        response = self.fetch(PATH_PREFIX + '/collections/datasets')
         self.assertResourceNotFoundResponse(response)
-        response = self.fetch('/catalog/collections/datacubes')
+        response = self.fetch(PATH_PREFIX + '/collections/datacubes')
         self.assertResponseOK(response)
 
-    def test_fetch_catalog_collection_items(self):
-        response = self.fetch('/catalog/collections/datacubes/items')
+    def test_fetch_catalog_collection_datacubes_items(self):
+        response = self.fetch(PATH_PREFIX + '/collections/datacubes/items')
         self.assertResponseOK(response)
-        response = self.fetch('/catalog/collections/datacubes/items'
+        response = self.fetch(PATH_PREFIX + '/collections/datacubes/items'
                               '?limit=1&cursor=1')
         self.assertResponseOK(response)
-        response = self.fetch('/catalog/collections/datasets')
+        response = self.fetch(PATH_PREFIX + '/collections/datasets')
         self.assertResourceNotFoundResponse(response)
 
+    def test_fetch_catalog_collection_single_items(self):
+        response = self.fetch(PATH_PREFIX + '/collections/demo/items')
+        self.assertResponseOK(response)
+
     def test_fetch_catalog_collection_item(self):
-        response = self.fetch('/catalog/collections/datacubes/items/demo')
+        response = self.fetch(PATH_PREFIX +
+                              '/collections/datacubes/items/demo')
         self.assertResponseOK(response)
-        response = self.fetch('/catalog/collections/datacubes/items/demox')
+        response = self.fetch(PATH_PREFIX +
+                              '/collections/datacubes/items/demox')
         self.assertResourceNotFoundResponse(response)
-        response = self.fetch('/catalog/collections/datasets/items/demo')
+        response = self.fetch(PATH_PREFIX + '/collections/datasets/items/demo')
         self.assertResourceNotFoundResponse(response)
 
     def test_fetch_catalog_search_by_kw(self):
-        response = self.fetch('/catalog/search', method='GET')
+        response = self.fetch(PATH_PREFIX + '/search', method='GET')
         self.assertResponseOK(response)
 
     def test_fetch_catalog_search_by_json(self):
-        response = self.fetch('/catalog/search', method='POST')
+        response = self.fetch(PATH_PREFIX + '/search', method='POST')
+        self.assertResponseOK(response)
+
+    def test_fetch_collection_queryables(self):
+        response = self.fetch(
+            f'{PATH_PREFIX}/collections/{DEFAULT_COLLECTION_ID}/queryables',
+            method='GET'
+        )
         self.assertResponseOK(response)
 
 
@@ -83,5 +99,5 @@ class StacRoutesTestCog(RoutesTestCase):
             }]}
 
     def test_fetch_catalog_collection_items(self):
-        response = self.fetch('/catalog/collections/datacubes/items')
+        response = self.fetch(PATH_PREFIX + '/collections/datacubes/items')
         self.assertResponseOK(response)