diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0510fab..142dd16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,13 +59,50 @@ jobs: name: ${{ matrix.python-version }} fail_ci_if_error: false + publish: + needs: [tests] + runs-on: ubuntu-latest + if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.LATEST_PY_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install wheel twine build + python -m pip install . + + - name: Set tag version + id: tag + run: | + echo "version=${GITHUB_REF#refs/*/}" + echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + + - name: Set module version + id: module + run: | + echo version=$(python -c'import titiler.stacapi; print(titiler.stacapi.__version__)') >> $GITHUB_OUTPUT + + - name: Build and publish + if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}} + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + rm -rf dist + python -m build + twine upload dist/* publish-docker: needs: [tests] # runs on push to main, on tag creation, and on release if: github.ref == 'refs/heads/main' || startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' runs-on: ubuntu-latest - + steps: - name: Checkout uses: actions/checkout@v4 @@ -110,4 +147,4 @@ jobs: file: dockerfiles/Dockerfile push: true tags: | - ghcr.io/developmentseed/titiler-stacapi:${{ steps.tag.outputs.version }} \ No newline at end of file + ghcr.io/developmentseed/titiler-stacapi:${{ steps.tag.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..6a978a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ + + +## [Unreleased] + +## [0.1.0] - 2024-06-11 + +* initial release + +[Unreleased]: +[0.1.0]: diff --git a/README.md b/README.md index a25464b..e980326 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ uvicorn titiler.stacapi.main:app --port 8000 ``` $ git clone https://github.com/developmentseed/titiler-stacapi.git $ cd titiler-stacapi +$ export TITILER_STACAPI_STAC_API_URL=https://api.stac $ docker-compose up --build api ``` @@ -63,6 +64,12 @@ It runs `titiler.stacapi` using Gunicorn web server. ![](https://github.com/developmentseed/titiler-stacapi/assets/10407788/2e53bfe3-402a-4c57-bf61-c055e32f1362) +### WMTS and the Render extension + +`titiler-stacapi` makes extensive use of the [**Render**](https://github.com/stac-extensions/render) extension, specifically at the `Collection` level. +By using the render's metadata, the `/wmts` endpoint (from the `OGCWMTSFactory` factory) can populate a set of `layers` returned by the `GetCapabilities` service. + + ## Contribution & Development See [CONTRIBUTING.md](https://github.com//developmentseed/titiler-stacapi/blob/main/CONTRIBUTING.md) diff --git a/docker-compose.yml b/docker-compose.yml index f1e7205..bf2d679 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,10 +37,14 @@ services: # setting the configuration option CPL_VSIL_CURL_CACHE_SIZE (in bytes). - CPL_VSIL_CURL_CACHE_SIZE=200000000 # TiTiler Config + # - RIO_TILER_MAX_THREADS= - MOSAIC_CONCURRENCY=5 # AWS S3 endpoint config # - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} # - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} # TiTiler STAC API Config - TITILER_STACAPI_API_DEBUG=TRUE - - TITILER_STACAPI_STAC_API_URL= \ No newline at end of file + - TITILER_STACAPI_STAC_API_URL=${TITILER_STACAPI_STAC_API_URL} + command: + # You can also overwrite the CMD option and use simple `uvicorn` ASGI server + bash -c "uvicorn titiler.stacapi.main:app --port 8081 --host 0.0.0.0" diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a2f0f9f..e39746c 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -18,10 +18,10 @@ nav: - Home: index.md - Endpoints: - endpoints/index.md - - OGC WMTS: endpoints/ogc_wmts_endpoints.md - - Collections: endpoints/collections_endpoints.md - - Items: endpoints/items_endpoints.md - - TileMatrixSet: endpoints/tms_endpoints.md + - OGC Web Map Tile Service: endpoints/ogc_wmts_endpoints.md + - STAC Collections: endpoints/collections_endpoints.md + - STAC Items: endpoints/items_endpoints.md + - OGC TileMatrix Schemes: endpoints/tms_endpoints.md - Customization: - Authentication: custom/application_with_auth.md - Technical Considerations: technical-considerations.md diff --git a/docs/src/endpoints/ogc_wmts_endpoints.md b/docs/src/endpoints/ogc_wmts_endpoints.md index d7c0c08..cc6a2ee 100644 --- a/docs/src/endpoints/ogc_wmts_endpoints.md +++ b/docs/src/endpoints/ogc_wmts_endpoints.md @@ -2,10 +2,10 @@ ### OGC WMTS endpoints -| Method | URL | Output | Description -| ------ | ------------------------------------------------------------------------------------|------------------------------|-------------- -| `GET` | `/wmts` | XML or image/bin or GeoJSON | OGC Web map tile service (KVP encoding) -| `GET` | `/{LAYER}/{STYLE}/{TIME}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{FORMAT}` | image/bin | return a web map tile image +| Method | URL | Output | Description +| ------ | -------------------------------------------------------------------------------------------|------------------------------|-------------- +| `GET` | `/wmts` | XML or image/bin or GeoJSON | OGC Web map tile service (KVP encoding) +| `GET` | `/layers/{LAYER}/{STYLE}/{TIME}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{FORMAT}` | image/bin | OGC GetTile (REST encoding) ### WMTS (GetCapabilities / GetTile / GetFeatureInfo) - KVP Encoding @@ -45,7 +45,7 @@ Example: ### GetTile - REST -`:endpoint:/{LAYER}/{STYLE}/{TIME}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{FORMAT}` +`:endpoint:/layers/{LAYER}/{STYLE}/{TIME}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{FORMAT}` - PathParams: - **Layer** (str): Layer identifier diff --git a/pyproject.toml b/pyproject.toml index 7e04952..47c2db1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ test = [ "pytest-cov", "pytest-asyncio", "httpx", + "owslib", ] [project.urls] diff --git a/tests/conftest.py b/tests/conftest.py index 29569ec..75922a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,8 @@ def app(monkeypatch): """App fixture.""" monkeypatch.setenv("TITILER_STACAPI_STAC_API_URL", "http://something.stac") + monkeypatch.setenv("TITILER_STACAPI_API_DEBUG", "TRUE") + monkeypatch.setenv("TITILER_STACAPI_CACHE_DISABLE", "TRUE") from titiler.stacapi.main import app diff --git a/tests/fixtures/1040010082988200-visual.tif b/tests/fixtures/1040010082988200-visual.tif new file mode 100644 index 0000000..163bdf9 Binary files /dev/null and b/tests/fixtures/1040010082988200-visual.tif differ diff --git a/tests/fixtures/46_033111301201_1040010082988200.json b/tests/fixtures/46_033111301201_1040010082988200.json new file mode 100644 index 0000000..6dc5abb --- /dev/null +++ b/tests/fixtures/46_033111301201_1040010082988200.json @@ -0,0 +1,307 @@ +{ + "id": "46_033111301201_1040010082988200", + "bbox": [ + 91.88812130564935, + 21.518532895453674, + 91.93942487240946, + 21.520759340266974 + ], + "type": "Feature", + "links": [ + { + "rel": "collection", + "type": "application/json", + "href": "https://stac.eoapi.dev/collections/MAXAR_BayofBengal_Cyclone_Mocha_May_23" + }, + { + "rel": "parent", + "type": "application/json", + "href": "https://stac.eoapi.dev/collections/MAXAR_BayofBengal_Cyclone_Mocha_May_23" + }, + { + "rel": "root", + "type": "application/json", + "href": "https://stac.eoapi.dev/" + }, + { + "rel": "self", + "type": "application/geo+json", + "href": "https://stac.eoapi.dev/collections/MAXAR_BayofBengal_Cyclone_Mocha_May_23/items/46_033111301201_1040010082988200" + } + ], + "assets": { + "visual": { + "href": "s3://maxar-opendata/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-visual.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "visual" + ], + "title": "Visual Image", + "eo:bands": [ + { + "name": "BAND_R", + "common_name": "red", + "description": "Red" + }, + { + "name": "BAND_G", + "common_name": "green", + "description": "Green" + }, + { + "name": "BAND_B", + "common_name": "blue", + "description": "Blue" + } + ], + "alternate": { + "public": { + "href": "https://maxar-opendata.s3.amazonaws.com/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-visual.tif", + "title": "Public Access" + } + }, + "proj:bbox": [ + 384843.75, + 2374843.75, + 390156.25, + 2380156.25 + ], + "proj:shape": [ + 17408, + 17408 + ], + "proj:transform": [ + 0.30517578125, + 0, + 384843.75, + 0, + -0.30517578125, + 2380156.25, + 0, + 0, + 1 + ] + }, + "data-mask": { + "href": "s3://maxar-opendata/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-data-mask.gpkg", + "type": "application/geopackage+sqlite3", + "roles": [ + "data-mask" + ], + "title": "Data Mask", + "alternate": { + "public": { + "href": "https://maxar-opendata.s3.amazonaws.com/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-data-mask.gpkg", + "title": "Public Access" + } + } + }, + "ms_analytic": { + "href": "s3://maxar-opendata/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-ms.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "title": "Multispectral Image", + "eo:bands": [ + { + "name": "BAND_C", + "common_name": "coastal", + "description": "Coastal Blue" + }, + { + "name": "BAND_B", + "common_name": "blue", + "description": "Blue" + }, + { + "name": "BAND_G", + "common_name": "green", + "description": "Green" + }, + { + "name": "BAND_Y", + "common_name": "yellow", + "description": "Yellow" + }, + { + "name": "BAND_R", + "common_name": "red", + "description": "Red" + }, + { + "name": "BAND_RE", + "common_name": "rededge", + "description": "Red Edge 1" + }, + { + "name": "BAND_N", + "common_name": "nir08", + "description": "Near Infrared 1" + }, + { + "name": "BAND_N2", + "common_name": "nir09", + "description": "Near Infrared 2" + } + ], + "alternate": { + "public": { + "href": "https://maxar-opendata.s3.amazonaws.com/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-ms.tif", + "title": "Public Access" + } + }, + "proj:bbox": [ + 384843.75, + 2374843.75, + 390156.25, + 2380156.25 + ], + "proj:shape": [ + 3993, + 3993 + ], + "proj:transform": [ + 1.3304532932632107, + 0, + 384843.75, + 0, + -1.3304532932632107, + 2380156.25, + 0, + 0, + 1 + ] + }, + "pan_analytic": { + "href": "s3://maxar-opendata/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-pan.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "title": "Panchromatic Image", + "eo:bands": [ + { + "name": "BAND_P", + "description": "Pan" + } + ], + "alternate": { + "public": { + "href": "https://maxar-opendata.s3.amazonaws.com/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14/1040010082988200-pan.tif", + "title": "Public Access" + } + }, + "proj:bbox": [ + 384843.75, + 2374843.75, + 390156.25, + 2380156.25 + ], + "proj:shape": [ + 15972, + 15972 + ], + "proj:transform": [ + 0.3326133233158027, + 0, + 384843.75, + 0, + -0.3326133233158027, + 2380156.25, + 0, + 0, + 1 + ] + } + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 91.88812130564935, + 21.520425546284702 + ], + [ + 91.939408735678, + 21.520759340266974 + ], + [ + 91.93942487240946, + 21.518532895453674 + ], + [ + 91.88813292057566, + 21.518896903912832 + ], + [ + 91.88812130564935, + 21.520425546284702 + ] + ] + ] + }, + "collection": "MAXAR_BayofBengal_Cyclone_Mocha_May_23", + "properties": { + "gsd": 0.34, + "quadkey": "033111301201", + "datetime": "2023-03-14T04:30:25Z", + "platform": "WV03", + "utm_zone": 46, + "grid:code": "MXRA-Z46-033111301201", + "proj:bbox": [ + 384843.75, + 2379910.40011545, + 390156.25, + 2380156.25 + ], + "proj:epsg": 32646, + "catalog_id": "1040010082988200", + "view:azimuth": 161.9, + "proj:geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 384843.75, + 2380156.25 + ], + [ + 390156.25, + 2380156.25 + ], + [ + 390156.25, + 2379910.40011545 + ], + [ + 384843.75, + 2379988.3126768884 + ], + [ + 384843.75, + 2380156.25 + ] + ] + ] + }, + "tile:data_area": 1, + "view:off_nadir": 19.5, + "tile:clouds_area": 0, + "view:sun_azimuth": 134.3, + "view:sun_elevation": 57.2, + "tile:clouds_percent": 0, + "ard_metadata_version": "0.0.1", + "view:incidence_angle": 68.5 + }, + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/view/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json", + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/raster/v1.1.0/schema.json", + "https://stac-extensions.github.io/grid/v1.0.0/schema.json", + "https://stac-extensions.github.io/alternate-assets/v1.1.0/schema.json" + ] + } diff --git a/tests/fixtures/catalog.json b/tests/fixtures/catalog.json new file mode 100644 index 0000000..4f2cb24 --- /dev/null +++ b/tests/fixtures/catalog.json @@ -0,0 +1,175 @@ +{ + "collections": [ + { + "id": "MAXAR_BayofBengal_Cyclone_Mocha_May_23", + "type": "Collection", + "links": [ + { + "rel": "items", + "type": "application/geo+json", + "href": "https://stac.endpoint.io/collections/MAXAR_BayofBengal_Cyclone_Mocha_May_23/items" + }, + { + "rel": "parent", + "type": "application/json", + "href": "https://stac.endpoint.io/" + }, + { + "rel": "root", + "type": "application/json", + "href": "https://stac.endpoint.io/" + }, + { + "rel": "self", + "type": "application/json", + "href": "https://stac.endpoint.io/collections/MAXAR_BayofBengal_Cyclone_Mocha_May_23" + } + ], + "title": "Bay of Bengal Cyclone Mocha 2023", + "extent": { + "spatial": { + "bbox": [ + [ + 91.831615, + 19.982078842323997, + 92.97426268500965, + 21.666101 + ], + [ + 92.567815, + 20.18811887678192, + 92.74417544237298, + 20.62968532404085 + ], + [ + 92.72278776887262, + 20.104801, + 92.893524, + 20.630214 + ], + [ + 92.75855246040959, + 19.982078842323997, + 92.89682495377032, + 20.514473160464657 + ], + [ + 92.84253515935835, + 19.984656587012033, + 92.97426268500965, + 20.514418665444474 + ], + [ + 91.831615, + 21.518411, + 91.957078, + 21.666101 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2023-01-03T04:30:17Z", + "2023-05-22T04:35:25Z" + ] + ] + } + }, + "license": "CC-BY-NC-4.0", + "renders": { + "visual": { + "title": "Visual Image", + "assets": [ + "visual" + ], + "asset_bidx": "visual|1,2,3", + "minmax_zoom": [ + 8, + 22 + ], + "tilematrixsets": { + "WebMercatorQuad": [ + 8, + 22 + ] + } + }, + "color": { + "title": "Colored Image", + "assets": [ + "visual" + ], + "asset_bidx": "visual|1", + "colormap": { + "1": [0, 0, 0, 255], + "1000": [255, 255, 255, 255] + } + }, + "visualr": { + "title": "Rescaled Image", + "assets": [ + "visual" + ], + "asset_bidx": "visual|1", + "rescale": [ + [0, 100] + ] + } + }, + "description": "Maxar OpenData | Cyclone Mocha, a category five cyclone with 130 mph winds and torrential rain, hit parts of Myanmar and Bangladesh, forcing mass evacuations ahead of the storm. The cyclone, one of the most powerful to hit the region in the last decade, made landfall on Sunday, May 14, 2023, near Sittwe in Myanmar's Rakhine state. Rain and a storm surge caused widespread flooding in low-lying areas. The United National Office Coordination of Humanitarian Affairs stated that there had been extensive damage among already vulnerable communities and that communications with the affected areas have been difficult.", + "item_assets": { + "visual": { + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "visual" + ], + "title": "Visual Image" + }, + "data-mask": { + "type": "application/geopackage+sqlite3", + "roles": [ + "data-mask" + ], + "title": "Data Mask" + }, + "ms_analytic": { + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "title": "Multispectral Image" + }, + "pan_analytic": { + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "roles": [ + "data" + ], + "title": "Panchromatic Image" + } + }, + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/item-assets/v1.0.0/schema.json", + "https://stac-extensions.github.io/render/v1.0.0/schema.json" + ] + } + ], + "links": [ + { + "rel": "root", + "type": "application/json", + "href": "https://stac.endpoint.io/" + }, + { + "rel": "parent", + "type": "application/json", + "href": "https://stac.endpoint.io/" + }, + { + "rel": "self", + "type": "application/json", + "href": "https://stac.endpoint.io/collections" + } + ] +} diff --git a/tests/test_app.py b/tests/test_app.py index b0711bd..3b1d1fb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -70,3 +70,12 @@ def test_docs(app): response = app.get("/api.html") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] + + +def test_debug(app): + """Test / endpoint.""" + response = app.get("/debug") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json() + assert body["url"] == "http://something.stac" diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..379962d --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,53 @@ +"""test render extension.""" + +import json +import os +from unittest.mock import patch + +import pystac + +from titiler.core import dependencies +from titiler.stacapi.factory import get_dependency_params, get_layer_from_collections + +catalog_json = os.path.join(os.path.dirname(__file__), "fixtures", "catalog.json") + + +@patch("titiler.stacapi.factory.Client") +def test_render(client): + """test STAC items endpoints.""" + + with open(catalog_json, "r") as f: + collections = [ + pystac.Collection.from_dict(c) for c in json.loads(f.read())["collections"] + ] + client.open.return_value.get_collections.return_value = collections + + collections_render = get_layer_from_collections( + "https://something.stac", None, None + ) + assert len(collections_render) == 3 + + visual = collections_render["MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual"] + assert visual["bbox"] + assert visual["tilematrixsets"]["WebMercatorQuad"] + assert visual["time"] + assert visual["render"]["asset_bidx"] + + color = collections_render["MAXAR_BayofBengal_Cyclone_Mocha_May_23_color"]["render"] + assert isinstance(color["colormap"], str) + + cmap = get_dependency_params( + dependency=dependencies.ColorMapParams, + query_params=color, + ) + assert cmap + + visualr = collections_render["MAXAR_BayofBengal_Cyclone_Mocha_May_23_visualr"][ + "render" + ] + assert isinstance(visualr["rescale"][0], str) + rescale = get_dependency_params( + dependency=dependencies.RescalingParams, + query_params=visualr, + ) + assert rescale diff --git a/tests/test_wmts.py b/tests/test_wmts.py new file mode 100644 index 0000000..ffb3220 --- /dev/null +++ b/tests/test_wmts.py @@ -0,0 +1,501 @@ +"""Test titiler.stacapi Item endpoints.""" + +import json +import os +from unittest.mock import patch +from urllib.parse import parse_qs + +import pystac +import rasterio +from owslib.wmts import WebMapTileService + +item_json = os.path.join( + os.path.dirname(__file__), "fixtures", "46_033111301201_1040010082988200.json" +) +catalog_json = os.path.join(os.path.dirname(__file__), "fixtures", "catalog.json") + +DATA_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + + +def mock_rasterio_open(asset): + """Mock rasterio Open.""" + asset = asset.replace( + "s3://maxar-opendata/events/BayofBengal-Cyclone-Mocha-May-23/ard/46/033111301201/2023-03-14", + DATA_DIR, + ) + return rasterio.open(asset) + + +@patch("titiler.stacapi.factory.Client") +def test_wmts_getcapabilities(client, app): + """test STAC items endpoints.""" + with open(catalog_json, "r") as f: + collections = [ + pystac.Collection.from_dict(c) for c in json.loads(f.read())["collections"] + ] + client.open.return_value.get_collections.return_value = collections + + # Missing Service + response = app.get("/wmts") + assert response.status_code == 400 + + # Invalid Service + response = app.get("/wmts", params={"service": "WMS"}) + assert response.status_code == 400 + + # Missing Version + response = app.get("/wmts", params={"service": "WMTS"}) + assert response.status_code == 400 + + # Invalid Version + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "2.0.0", + }, + ) + assert response.status_code == 400 + + # Missing Request + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + }, + ) + assert response.status_code == 400 + + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getSomething", + }, + ) + assert response.status_code == 400 + + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getcapabilities", + }, + ) + assert response.status_code == 200 + wmts = WebMapTileService(url="/wmts", xml=response.text.encode()) + layers = list(wmts.contents) + assert len(layers) == 3 + assert "MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual" in layers + assert "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color" in layers + assert "MAXAR_BayofBengal_Cyclone_Mocha_May_23_visualr" in layers + + layer = wmts["MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual"] + assert "WebMercatorQuad" in layer.tilematrixsetlinks + assert "TIME" in layer.dimensions + assert ["default"] == list(layer.styles.keys()) + assert ["image/png"] == layer.formats + + params = layer.resourceURLs[0]["template"].split("?")[1] + query = parse_qs(params) + assert query["assets"] == ["visual"] + assert query["asset_bidx"] == ["visual|1,2,3"] + + +@patch("rio_tiler.io.rasterio.rasterio") +@patch("titiler.stacapi.factory.STACAPIBackend.get_assets") +@patch("titiler.stacapi.factory.Client") +def test_wmts_gettile(client, get_assets, rio, app): + """test STAC items endpoints.""" + rio.open = mock_rasterio_open + + with open(catalog_json, "r") as f: + collections = [ + pystac.Collection.from_dict(c) for c in json.loads(f.read())["collections"] + ] + client.open.return_value.get_collections.return_value = collections + + with open(item_json, "r") as f: + get_assets.return_value = [json.loads(f.read())] + + # missing keys + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + }, + ) + assert response.status_code == 400 + + # invalid format + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/yo", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 0, + "tilerow": 0, + "tilecol": 0, + }, + ) + assert response.status_code == 400 + + # invalid layer + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_colorrrrrrrrr", + "style": "", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 0, + "tilerow": 0, + "tilecol": 0, + }, + ) + assert response.status_code == 400 + assert ( + "Invalid 'LAYER' parameter: MAXAR_BayofBengal_Cyclone_Mocha_May_23_colorrrrrrrrr" + in response.json()["detail"] + ) + + # invalid style + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "something", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 0, + "tilerow": 0, + "tilecol": 0, + }, + ) + assert response.status_code == 400 + assert "Invalid STYLE parameters something" in response.json()["detail"] + + # Missing Time + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 15, + "tilerow": 12849, + "tilecol": 8589, + }, + ) + assert response.status_code == 400 + assert "Missing 'TIME' parameter" in response.json()["detail"] + + # Invalid Time + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 15, + "tilerow": 12849, + "tilecol": 8589, + "TIME": "2000-01-01", + }, + ) + assert response.status_code == 400 + assert "Invalid 'TIME' parameter:" in response.json()["detail"] + + # Invalid TMS + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQua", + "tilematrix": 15, + "tilerow": 12849, + "tilecol": 8589, + "TIME": "2023-01-05", + }, + ) + assert response.status_code == 400 + assert "Invalid 'TILEMATRIXSET' parameter" in response.json()["detail"] + + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "gettile", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 14, + "tilerow": 7188, + "tilecol": 12375, + "TIME": "2023-01-05", + }, + ) + assert response.status_code == 200 + + response = app.get( + "/wmts", + params={ + "SERVICE": "WMTS", + "VERSION": "1.0.0", + "REQUEST": "getTile", + "LAYER": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual", + "STYLE": "default", + "FORMAT": "image/png", + "TILEMATRIXSET": "WebMercatorQuad", + "TILEMATRIX": 14, + "TILEROW": 7188, + "TILECOL": 12375, + "TIME": "2023-01-05", + }, + ) + assert response.status_code == 200 + + +@patch("rio_tiler.io.rasterio.rasterio") +@patch("titiler.stacapi.factory.STACAPIBackend.get_assets") +@patch("titiler.stacapi.factory.Client") +def test_wmts_getfeatureinfo(client, get_assets, rio, app): + """test STAC items endpoints.""" + rio.open = mock_rasterio_open + + with open(catalog_json, "r") as f: + collections = [ + pystac.Collection.from_dict(c) for c in json.loads(f.read())["collections"] + ] + client.open.return_value.get_collections.return_value = collections + + with open(item_json, "r") as f: + get_assets.return_value = [json.loads(f.read())] + + # missing keys + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + }, + ) + assert response.status_code == 400 + + # invalid infoformat + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual", + "style": "", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 0, + "tilerow": 0, + "tilecol": 0, + "TIME": "2023-01-05", + "infoformat": "application/xml", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 400 + assert "Invalid 'InfoFormat' parameter:" in response.json()["detail"] + + # invalid layer + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_colorrrrrrrrr", + "style": "", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 0, + "tilerow": 0, + "tilecol": 0, + "infoformat": "application/geo+json", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 400 + assert ( + "Invalid 'LAYER' parameter: MAXAR_BayofBengal_Cyclone_Mocha_May_23_colorrrrrrrrr" + in response.json()["detail"] + ) + + # invalid style + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "something", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 0, + "tilerow": 0, + "tilecol": 0, + "infoformat": "application/geo+json", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 400 + assert "Invalid STYLE parameters something" in response.json()["detail"] + + # Missing Time + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 15, + "tilerow": 12849, + "tilecol": 8589, + "infoformat": "application/geo+json", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 400 + assert "Missing 'TIME' parameter" in response.json()["detail"] + + # Invalid Time + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 15, + "tilerow": 12849, + "tilecol": 8589, + "TIME": "2000-01-01", + "infoformat": "application/geo+json", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 400 + assert "Invalid 'TIME' parameter:" in response.json()["detail"] + + # Invalid TMS + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_color", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQua", + "tilematrix": 15, + "tilerow": 12849, + "tilecol": 8589, + "TIME": "2023-01-05", + "infoformat": "application/geo+json", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 400 + assert "Invalid 'TILEMATRIXSET' parameter" in response.json()["detail"] + + response = app.get( + "/wmts", + params={ + "service": "WMTS", + "version": "1.0.0", + "request": "getfeatureinfo", + "layer": "MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual", + "style": "default", + "format": "image/png", + "tilematrixset": "WebMercatorQuad", + "tilematrix": 14, + "tilerow": 7188, + "tilecol": 12375, + "TIME": "2023-01-05", + "infoformat": "application/geo+json", + "i": 0, + "j": 0, + }, + ) + assert response.status_code == 200 + + +@patch("rio_tiler.io.rasterio.rasterio") +@patch("titiler.stacapi.factory.STACAPIBackend.get_assets") +@patch("titiler.stacapi.factory.Client") +def test_wmts_gettile_REST(client, get_assets, rio, app): + """test STAC items endpoints.""" + rio.open = mock_rasterio_open + + with open(catalog_json, "r") as f: + collections = [ + pystac.Collection.from_dict(c) for c in json.loads(f.read())["collections"] + ] + client.open.return_value.get_collections.return_value = collections + + with open(item_json, "r") as f: + get_assets.return_value = [json.loads(f.read())] + + # missing keys + response = app.get( + "/layers/MAXAR_BayofBengal_Cyclone_Mocha_May_23_visual/default/2023-01-05/WebMercatorQuad/14/12375/7188.png", + params={ + "assets": ["visual"], + "asset_bidx": ["visual|1,2,3"], + }, + ) + assert response.headers["content-type"] == "image/png" diff --git a/titiler/stacapi/__init__.py b/titiler/stacapi/__init__.py index f3ff220..1a5c8d5 100644 --- a/titiler/stacapi/__init__.py +++ b/titiler/stacapi/__init__.py @@ -1,3 +1,3 @@ -"""titiler.cmr""" +"""titiler.stacapi""" __version__ = "0.1.0" diff --git a/titiler/stacapi/factory.py b/titiler/stacapi/factory.py index 5e78239..4e8b348 100644 --- a/titiler/stacapi/factory.py +++ b/titiler/stacapi/factory.py @@ -552,7 +552,7 @@ class WMTSMediaType(str, Enum): TTLCache(maxsize=cache_config.maxsize, ttl=cache_config.ttl), key=lambda url, headers, supported_tms: hashkey(url, json.dumps(headers)), ) -def get_layer_from_collections( +def get_layer_from_collections( # noqa: C901 url: str, headers: Optional[Dict] = None, supported_tms: Optional[TileMatrixSets] = None, @@ -653,11 +653,29 @@ def get_layer_from_collections( for x in range(0, (end_date - start_date).days + 1) ] - # TODO: - # special encoding for ColorMaps render = layer["render"] or {} - if "colormap" in render: - render["colormap"] = json.dumps(render["colormap"]) + + # special encoding for rescale + # Per Specification, the rescale entry is a 2d array in form of `[[min, max], [min,max]]` + # We need to convert this to `['{min},{max}', '{min},{max}']` for titiler dependency + if rescale := render.pop("rescale", None): + rescales = [] + for r in rescale: + if not isinstance(r, str): + rescales.append(",".join(map(str, r))) + else: + rescales.append(r) + + render["rescale"] = rescales + + # special encoding for ColorMaps + # Per Specification, the colormap is a JSON object. TiTiler dependency expects a string encoded dict + if colormap := render.pop("colormap", None): + if not isinstance(colormap, str): + colormap = json.dumps(colormap) + + render["colormap"] = colormap + qs = urlencode( [(k, v) for k, v in render.items() if v is not None], doseq=True, @@ -730,7 +748,7 @@ def get_tile( # noqa: C901 if layer_time and "time" not in req: raise HTTPException( status_code=400, - detail=f"Missing TIME parameters for layer {layer['id']}", + detail=f"Missing 'TIME' parameters for layer {layer['id']}", ) if layer_time and req_time not in layer_time: @@ -823,19 +841,11 @@ def get_tile( # noqa: C901 ): image = post_process(image) - if "rescale" in query_params: - rescales = [] - for r in query_params["rescale"]: - if not isinstance(r, str): - rescales.append(",".join(map(str, r))) - else: - rescales.append(r) - - if rescale := get_dependency_params( - dependency=self.rescale_dependency, - query_params={"rescale": rescales}, - ): - image.rescale(rescale) + if rescale := get_dependency_params( + dependency=self.rescale_dependency, + query_params=query_params, + ): + image.rescale(rescale) if color_formula := get_dependency_params( dependency=self.color_formula_dependency, @@ -1163,7 +1173,8 @@ def web_map_tile_service( # noqa: C901 # GetFeatureInfo Request elif request_type.lower() == "getfeatureinfo": req_keys = { - "service" "request", # wmts + "service", + "request", "version", "layer", "style", @@ -1184,10 +1195,10 @@ def web_map_tile_service( # noqa: C901 detail=f"Missing '{request_type}' parameters: {missing_keys}", ) - if req["infoformat"] != "application/xml": + if req["infoformat"] != "application/geo+json": raise HTTPException( status_code=400, - detail=f"Invalid 'InfoFormat' parameter: {req['infoformat']}. Should be 'application/xml'.", + detail=f"Invalid 'InfoFormat' parameter: {req['infoformat']}. Should be 'application/geo+json'.", ) if req["layer"] not in layers: @@ -1236,7 +1247,7 @@ def web_map_tile_service( # noqa: C901 "coordinates": (xs_wgs84[0], ys_wgs84[0]), }, "properties": { - "values": image.data[:, j, i], + "values": image.data[:, j, i].tolist(), "I": i, "J": j, "style": req_style, @@ -1247,7 +1258,6 @@ def web_map_tile_service( # noqa: C901 "tileCol": req["tilecol"], }, } - return GeoJSONResponse(geojson) else: @@ -1257,7 +1267,7 @@ def web_map_tile_service( # noqa: C901 ) @self.router.get( - "/{LAYER}/{STYLE}/{TIME}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{FORMAT}", + "/layers/{LAYER}/{STYLE}/{TIME}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{FORMAT}", **img_endpoint_params, ) def WMTS_getTile( @@ -1332,7 +1342,7 @@ def WMTS_getTile( reader_params=Depends(self.reader_dependency), env=Depends(self.environment_dependency), ): - """Create map tile.""" + """OGC WMTS GetTile (REST encoding)""" search_query = {"collections": [collectionId], "datetime": timeId} tms = self.supported_tms.get(tileMatrixSetId) diff --git a/titiler/stacapi/main.py b/titiler/stacapi/main.py index 5abd396..610801a 100644 --- a/titiler/stacapi/main.py +++ b/titiler/stacapi/main.py @@ -83,6 +83,17 @@ app.add_middleware(LoggerMiddleware, headers=True, querystrings=True) optional_headers = [OptionalHeader.server_timing, OptionalHeader.x_assets] +############################################################################### +# OGC WMTS Endpoints +wmts = OGCWMTSFactory( + path_dependency=STACApiParams, + templates=templates, +) +app.include_router( + wmts.router, + tags=["OGC Web Map Tile Service"], +) + ############################################################################### # STAC COLLECTION Endpoints # Notes: @@ -118,21 +129,10 @@ prefix="/collections/{collection_id}/items/{item_id}", ) -############################################################################### -# OGC WMTS Endpoints -wmts = OGCWMTSFactory( - path_dependency=STACApiParams, - templates=templates, -) -app.include_router( - wmts.router, - tags=["Web Map Tile Service"], -) - ############################################################################### # Tiling Schemes Endpoints tms = TMSFactory() -app.include_router(tms.router, tags=["Tiling Schemes"]) +app.include_router(tms.router, tags=["OGC TileMatrix Schemes"]) ############################################################################### # Algorithms Endpoints @@ -223,3 +223,33 @@ def landing( ) return data + + +if settings.debug: + + @app.get("/debug", include_in_schema=False, tags=["DEBUG"]) + def debug(request: Request) -> Dict: + """APP Info.""" + + import rasterio + from fastapi import __version__ as fastapi_version + from pydantic import __version__ as pydantic_version + from rio_tiler import __version__ as rio_tiler_version + from starlette import __version__ as starlette_version + + from titiler.core import __version__ as titiler_version + + return { + "url": request.app.state.stac_url, + "versions": { + "titiler.stacapi": titiler_stacapi_version, + "titiler.core": titiler_version, + "rio-tiler": rio_tiler_version, + "rasterio": rasterio.__version__, + "gdal": rasterio.__gdal_version__, + "proj": rasterio.__proj_version__, + "fastapi": fastapi_version, + "starlette": starlette_version, + "pydantic": pydantic_version, + }, + } diff --git a/titiler/stacapi/models.py b/titiler/stacapi/models.py index bc5d82f..0ea5c25 100644 --- a/titiler/stacapi/models.py +++ b/titiler/stacapi/models.py @@ -1,4 +1,4 @@ -"""ogcapi pydantic models. +"""titiler.stacapi ogcapi pydantic models. This might be moved in an external python module @@ -27,7 +27,7 @@ class Link(BaseModel): str, Field( description="Supplies the URI to a remote resource (or resource fragment).", - example="http://data.example.com/buildings/123", + json_schema_extra={"example": "http://data.example.com/buildings/123"}, ), ] rel: Annotated[ @@ -40,7 +40,9 @@ class Link(BaseModel): Optional[MediaType], Field( description="A hint indicating what the media type of the result of dereferencing the link should be.", - example="application/geo+json", + json_schema_extra={ + "example": "application/geo+json", + }, ), ] = None templated: Annotated[ @@ -51,21 +53,27 @@ class Link(BaseModel): Optional[str], Field( description="A base path to retrieve semantic information about the variables used in URL template.", - example="/ogcapi/vars/", + json_schema_extra={ + "example": "/ogcapi/vars/", + }, ), ] = None hreflang: Annotated[ Optional[str], Field( description="A hint indicating what the language of the result of dereferencing the link should be.", - example="en", + json_schema_extra={ + "example": "en", + }, ), ] = None title: Annotated[ Optional[str], Field( description="Used to label the destination of a link such that it can be used as a human-readable identifier.", - example="Trierer Strasse 70, 53115 Bonn", + json_schema_extra={ + "example": "Trierer Strasse 70, 53115 Bonn", + }, ), ] = None length: Optional[int] = None