From 0b8ad4330b27f7730a2fcd6c58d43d7d957ef299 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 18:46:29 +0100 Subject: [PATCH 01/36] refactor: migrate to src layout and add linting/type-checking configs Move main.py into src/eo_api/ package layout. Add ruff, mypy, and pyright configurations to pyproject.toml. Update Makefile and README with new module path. Add ruff, mypy, pyright as dev dependencies. --- CLAUDE.md | 7 ++ Makefile | 2 +- README.md | 6 +- pyproject.toml | 70 +++++++++++++++- src/eo_api/__init__.py | 0 main.py => src/eo_api/main.py | 15 ++-- uv.lock | 153 ++++++++++++++++++++++++++++++++++ 7 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/eo_api/__init__.py rename main.py => src/eo_api/main.py (76%) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d39637e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +## Commit conventions + +- Use conventional commits (e.g. `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`) +- No Co-Authored-By or other attribution lines +- Never use emojis anywhere — not in commits, code, comments, or responses diff --git a/Makefile b/Makefile index d3c2765..af9cef3 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,4 @@ sync: uv sync run: - uv run uvicorn main:app --reload \ No newline at end of file + uv run uvicorn eo_api.main:app --reload \ No newline at end of file diff --git a/README.md b/README.md index 16a0213..1f6e78e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Copy `.env.example` to `.env` and adjust values as needed. Start the app: -`uv run uvicorn main:app --reload` +`uv run uvicorn eo_api.main:app --reload` ### Using pip (alternative) @@ -25,7 +25,7 @@ If you can't use uv (e.g. mixed conda/forge environments): python -m venv .venv source .venv/bin/activate pip install -e . -uvicorn main:app --reload +uvicorn eo_api.main:app --reload ``` ### Using conda @@ -34,7 +34,7 @@ uvicorn main:app --reload conda create -n dhis2-eo-api python=3.13 conda activate dhis2-eo-api pip install -e . -uvicorn main:app --reload +uvicorn eo_api.main:app --reload ``` ### Makefile targets diff --git a/pyproject.toml b/pyproject.toml index 6fb619e..1b071b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,4 +9,72 @@ dependencies = [ "pygeoapi>=0.22.0", "dhis2eo @ git+https://github.com/dhis2/dhis2eo.git@v1.1.0", "dhis2-client @ git+https://github.com/dhis2/dhis2-python-client.git@V0.3.0", -] \ No newline at end of file +] + +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +fixable = ["ALL"] +select = ["E", "W", "F", "I", "D"] +ignore = ["D203", "D213"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["D"] +"**/__init__.py" = ["D104"] +"src/**/*.py" = ["D102", "D105", "D107"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +docstring-code-format = true +docstring-code-line-length = "dynamic" + +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_unused_ignores = true +strict_equality = true +mypy_path = ["src"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = ["dhis2eo.*", "dhis2_client.*", "pygeoapi.*", "titiler.*"] +ignore_missing_imports = true + +[tool.pyright] +include = ["src", "tests"] +exclude = ["**/.venv"] +pythonVersion = "3.13" +typeCheckingMode = "strict" +useLibraryCodeForTypes = true +reportPrivateUsage = false +reportUnusedFunction = false +reportUnknownMemberType = false +reportUnknownArgumentType = false +reportUnknownParameterType = false +reportUnknownVariableType = false +reportMissingTypeArgument = false +reportMissingTypeStubs = false +reportUnknownLambdaType = false +reportMissingImports = "warning" +reportMissingModuleSource = false + +[dependency-groups] +dev = [ + "mypy>=1.19.1", + "pyright>=1.1.408", + "ruff>=0.15.2", +] diff --git a/src/eo_api/__init__.py b/src/eo_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/src/eo_api/main.py similarity index 76% rename from main.py rename to src/eo_api/main.py index 5656432..f154739 100644 --- a/main.py +++ b/src/eo_api/main.py @@ -1,16 +1,17 @@ +"""DHIS2 EO API - Earth observation data API for DHIS2.""" + from dotenv import load_dotenv from fastapi import FastAPI -from titiler.core.factory import (TilerFactory) - from starlette.middleware.cors import CORSMiddleware +from titiler.core.factory import TilerFactory load_dotenv() -from pygeoapi.starlette_app import APP as pygeoapi_app +from pygeoapi.starlette_app import APP as pygeoapi_app # noqa: E402 app = FastAPI() -# Bsed on: +# Bsed on: # https://docs.pygeoapi.io/en/stable/administration.html # https://dive.pygeoapi.io/advanced/downstream-applications/#starlette-and-fastapi # https://developmentseed.org/titiler/user_guide/getting_started/#4-create-your-titiler-application @@ -34,7 +35,9 @@ # Register all the COG endpoints automatically app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimized GeoTIFF"]) + # Optional: Add a welcome message for the root endpoint @app.get("/") -def read_index(): - return {"message": "Welcome to DHIS2 EO API"} \ No newline at end of file +def read_index() -> dict[str, str]: + """Return a welcome message for the root endpoint.""" + return {"message": "Welcome to DHIS2 EO API"} diff --git a/uv.lock b/uv.lock index eb02787..dd9ed25 100644 --- a/uv.lock +++ b/uv.lock @@ -591,6 +591,13 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pyright" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "dhis2-client", git = "https://github.com/dhis2/dhis2-python-client.git?rev=V0.3.0" }, @@ -601,6 +608,13 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.41.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pyright", specifier = ">=1.1.408" }, + { name = "ruff", specifier = ">=0.15.2" }, +] + [[package]] name = "fastapi" version = "0.131.0" @@ -940,6 +954,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + [[package]] name = "locket" version = "1.0.0" @@ -1136,6 +1197,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/cf/be4e93afbfa0def2cd6fac9302071db0bd6d0617999ecbf53f92b9398de3/multiurl-0.3.7-py3-none-any.whl", hash = "sha256:054f42974064f103be0ed55b43f0c32fc435a47dc7353a9adaffa643b99fa380", size = 21524, upload-time = "2025-07-29T11:57:03.191Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "netcdf4" version = "1.7.4" @@ -1163,6 +1260,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/2e/39d5e9179c543f2e6e149a65908f83afd9b6d64379a90789b323111761db/netcdf4-1.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:034220887d48da032cb2db5958f69759dbb04eb33e279ec6390571d4aea734fe", size = 2531682, upload-time = "2026-01-05T02:27:37.062Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numexpr" version = "2.14.1" @@ -1318,6 +1424,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pdbufr" version = "0.14.1" @@ -1647,6 +1762,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, ] +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + [[package]] name = "pyshp" version = "3.0.3" @@ -1979,6 +2107,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + [[package]] name = "shapely" version = "2.1.2" From 7322adef071d04db31071297ae1e0e80a3604732 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 18:46:57 +0100 Subject: [PATCH 02/36] chore: add make lint target --- Makefile | 6 +++++- README.md | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index af9cef3..1dc6c4a 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,8 @@ sync: uv sync run: - uv run uvicorn eo_api.main:app --reload \ No newline at end of file + uv run uvicorn eo_api.main:app --reload + +lint: + uv run ruff check --fix . + uv run ruff format . \ No newline at end of file diff --git a/README.md b/README.md index 1f6e78e..c4e09b0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ uvicorn eo_api.main:app --reload - `make sync` — install dependencies with uv - `make run` — start the app with uv +- `make lint` — run ruff linting and format checks Root endpoint: From 2030fb89b2d4fec746d9aac2d07e53bf208c9e5b Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 18:48:05 +0100 Subject: [PATCH 03/36] chore: add make help as default target --- Makefile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1dc6c4a..51876ee 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,14 @@ -sync: +.DEFAULT_GOAL := help + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}' + +sync: ## Install dependencies with uv uv sync -run: +run: ## Start the app with uvicorn uv run uvicorn eo_api.main:app --reload -lint: +lint: ## Run ruff linting and formatting (autofix) uv run ruff check --fix . uv run ruff format . \ No newline at end of file From dc14e1e419c384274011619ddc4cd101bc918d73 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 18:49:40 +0100 Subject: [PATCH 04/36] chore: add uv_build build system and make help target Add build-system config using uv_build backend so the src layout package installs correctly. Add make help as default target. --- pyproject.toml | 4 ++++ uv.lock | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1b071b4..59a7a70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["uv_build>=0.9.0"] +build-backend = "uv_build" + [project] name = "eo-api" version = "0.1.0" diff --git a/uv.lock b/uv.lock index dd9ed25..d27fe76 100644 --- a/uv.lock +++ b/uv.lock @@ -581,7 +581,7 @@ wheels = [ [[package]] name = "eo-api" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "dhis2-client" }, { name = "dhis2eo" }, From 13d2f74b813df29a543097a6db2e137c30fdd428 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 18:53:00 +0100 Subject: [PATCH 05/36] refactor: split main.py into routers and add Pydantic response models Extract route definitions into dedicated router modules (routers/root.py, routers/cog.py) and add Pydantic schemas (schemas.py). Clean up main.py by removing noisy comments, fixing the load_dotenv() placement, and switching to fastapi.middleware.cors. --- src/eo_api/main.py | 38 +++++++++++----------------------- src/eo_api/routers/__init__.py | 0 src/eo_api/routers/cog.py | 6 ++++++ src/eo_api/routers/root.py | 13 ++++++++++++ src/eo_api/schemas.py | 9 ++++++++ 5 files changed, 40 insertions(+), 26 deletions(-) create mode 100644 src/eo_api/routers/__init__.py create mode 100644 src/eo_api/routers/cog.py create mode 100644 src/eo_api/routers/root.py create mode 100644 src/eo_api/schemas.py diff --git a/src/eo_api/main.py b/src/eo_api/main.py index f154739..74e8be3 100644 --- a/src/eo_api/main.py +++ b/src/eo_api/main.py @@ -1,43 +1,29 @@ -"""DHIS2 EO API - Earth observation data API for DHIS2.""" +"""DHIS2 EO API - Earth observation data API for DHIS2. + +load_dotenv() is called before pygeoapi import because pygeoapi +reads PYGEOAPI_CONFIG and PYGEOAPI_OPENAPI at import time. +""" from dotenv import load_dotenv -from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware -from titiler.core.factory import TilerFactory load_dotenv() +from fastapi import FastAPI # noqa: E402 +from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from pygeoapi.starlette_app import APP as pygeoapi_app # noqa: E402 -app = FastAPI() +from eo_api.routers import cog, root # noqa: E402 -# Bsed on: -# https://docs.pygeoapi.io/en/stable/administration.html -# https://dive.pygeoapi.io/advanced/downstream-applications/#starlette-and-fastapi -# https://developmentseed.org/titiler/user_guide/getting_started/#4-create-your-titiler-application -# https://github.com/developmentseed/titiler/blob/main/src/titiler/application/titiler/application/main.py +app = FastAPI() -# Add CORS middleware app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Allows all origins (for development - be more specific in production) + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# mount all pygeoapi endpoints to /ogcapi -app.mount(path="/ogcapi", app=pygeoapi_app) - -# Create a TilerFactory for Cloud-Optimized GeoTIFFs -cog = TilerFactory() - -# Register all the COG endpoints automatically +app.include_router(root.router) app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimized GeoTIFF"]) - - -# Optional: Add a welcome message for the root endpoint -@app.get("/") -def read_index() -> dict[str, str]: - """Return a welcome message for the root endpoint.""" - return {"message": "Welcome to DHIS2 EO API"} +app.mount(path="/ogcapi", app=pygeoapi_app) diff --git a/src/eo_api/routers/__init__.py b/src/eo_api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/eo_api/routers/cog.py b/src/eo_api/routers/cog.py new file mode 100644 index 0000000..2e0636e --- /dev/null +++ b/src/eo_api/routers/cog.py @@ -0,0 +1,6 @@ +"""Cloud Optimized GeoTIFF (COG) endpoints.""" + +from titiler.core.factory import TilerFactory + +cog = TilerFactory() +router = cog.router diff --git a/src/eo_api/routers/root.py b/src/eo_api/routers/root.py new file mode 100644 index 0000000..35886ab --- /dev/null +++ b/src/eo_api/routers/root.py @@ -0,0 +1,13 @@ +"""Root API endpoints.""" + +from fastapi import APIRouter + +from eo_api.schemas import MessageResponse + +router = APIRouter() + + +@router.get("/") +def read_index() -> MessageResponse: + """Return a welcome message for the root endpoint.""" + return MessageResponse(message="Welcome to DHIS2 EO API") diff --git a/src/eo_api/schemas.py b/src/eo_api/schemas.py new file mode 100644 index 0000000..9958c80 --- /dev/null +++ b/src/eo_api/schemas.py @@ -0,0 +1,9 @@ +"""Pydantic response models.""" + +from pydantic import BaseModel + + +class MessageResponse(BaseModel): + """Generic message response.""" + + message: str From e9e41ab76f1f1eda44b50a75ffaa96c8295ff465 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 18:56:14 +0100 Subject: [PATCH 06/36] refactor: rename MessageResponse to StatusMessage and extract ogcapi router Rename the generic MessageResponse model to StatusMessage for clarity. Move the pygeoapi mount into its own router module for future configuration. --- src/eo_api/main.py | 5 ++--- src/eo_api/routers/ogcapi.py | 5 +++++ src/eo_api/routers/root.py | 6 +++--- src/eo_api/schemas.py | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 src/eo_api/routers/ogcapi.py diff --git a/src/eo_api/main.py b/src/eo_api/main.py index 74e8be3..a2c92e6 100644 --- a/src/eo_api/main.py +++ b/src/eo_api/main.py @@ -10,9 +10,8 @@ from fastapi import FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 -from pygeoapi.starlette_app import APP as pygeoapi_app # noqa: E402 -from eo_api.routers import cog, root # noqa: E402 +from eo_api.routers import cog, ogcapi, root # noqa: E402 app = FastAPI() @@ -26,4 +25,4 @@ app.include_router(root.router) app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimized GeoTIFF"]) -app.mount(path="/ogcapi", app=pygeoapi_app) +app.mount(path="/ogcapi", app=ogcapi.app) diff --git a/src/eo_api/routers/ogcapi.py b/src/eo_api/routers/ogcapi.py new file mode 100644 index 0000000..aa16781 --- /dev/null +++ b/src/eo_api/routers/ogcapi.py @@ -0,0 +1,5 @@ +"""OGC API endpoints (pygeoapi).""" + +from pygeoapi.starlette_app import APP as pygeoapi_app + +app = pygeoapi_app diff --git a/src/eo_api/routers/root.py b/src/eo_api/routers/root.py index 35886ab..292d95e 100644 --- a/src/eo_api/routers/root.py +++ b/src/eo_api/routers/root.py @@ -2,12 +2,12 @@ from fastapi import APIRouter -from eo_api.schemas import MessageResponse +from eo_api.schemas import StatusMessage router = APIRouter() @router.get("/") -def read_index() -> MessageResponse: +def read_index() -> StatusMessage: """Return a welcome message for the root endpoint.""" - return MessageResponse(message="Welcome to DHIS2 EO API") + return StatusMessage(message="Welcome to DHIS2 EO API") diff --git a/src/eo_api/schemas.py b/src/eo_api/schemas.py index 9958c80..4052ca2 100644 --- a/src/eo_api/schemas.py +++ b/src/eo_api/schemas.py @@ -3,7 +3,7 @@ from pydantic import BaseModel -class MessageResponse(BaseModel): - """Generic message response.""" +class StatusMessage(BaseModel): + """Simple status message response.""" message: str From b05fb1fdb8b17af0ebfe8b59cef4c3c0a76001ec Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:03:12 +0100 Subject: [PATCH 07/36] docs: add explicit TilerFactory configuration with inline comments --- src/eo_api/routers/cog.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/eo_api/routers/cog.py b/src/eo_api/routers/cog.py index 2e0636e..36cd858 100644 --- a/src/eo_api/routers/cog.py +++ b/src/eo_api/routers/cog.py @@ -1,6 +1,19 @@ -"""Cloud Optimized GeoTIFF (COG) endpoints.""" +"""Cloud Optimized GeoTIFF (COG) endpoints powered by titiler.""" from titiler.core.factory import TilerFactory -cog = TilerFactory() +cog = TilerFactory( + # router_prefix should match the prefix used in app.include_router() + router_prefix="/cog", + # Endpoints to register (all True by default except add_ogc_maps) + add_preview=True, + add_part=True, + add_viewer=True, + # GDAL environment variables applied to every request + # environment_dependency=lambda: { + # "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR", + # "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES", + # }, +) + router = cog.router From 50d4a4fac3373bf0a30424d92670a95ef743b1bb Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:03:17 +0100 Subject: [PATCH 08/36] test: add pytest with initial test suite and make test target --- Makefile | 5 ++++- pyproject.toml | 2 ++ tests/__init__.py | 0 tests/conftest.py | 9 +++++++++ tests/test_root.py | 12 ++++++++++++ uv.lock | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_root.py diff --git a/Makefile b/Makefile index 51876ee..80af839 100644 --- a/Makefile +++ b/Makefile @@ -11,4 +11,7 @@ run: ## Start the app with uvicorn lint: ## Run ruff linting and formatting (autofix) uv run ruff check --fix . - uv run ruff format . \ No newline at end of file + uv run ruff format . + +test: ## Run tests with pytest + uv run pytest tests/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 59a7a70..7372960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,9 @@ reportMissingModuleSource = false [dependency-groups] dev = [ + "httpx>=0.28.0", "mypy>=1.19.1", "pyright>=1.1.408", + "pytest>=8.0.0", "ruff>=0.15.2", ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..08eb7de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from fastapi.testclient import TestClient + +from eo_api.main import app + + +@pytest.fixture +def client(): + return TestClient(app) diff --git a/tests/test_root.py b/tests/test_root.py new file mode 100644 index 0000000..bc3a252 --- /dev/null +++ b/tests/test_root.py @@ -0,0 +1,12 @@ +from eo_api.schemas import StatusMessage + + +def test_root_returns_200(client): + response = client.get("/") + assert response.status_code == 200 + + +def test_root_returns_welcome_message(client): + response = client.get("/") + result = StatusMessage.model_validate(response.json()) + assert result.message == "Welcome to DHIS2 EO API" diff --git a/uv.lock b/uv.lock index d27fe76..040313e 100644 --- a/uv.lock +++ b/uv.lock @@ -593,8 +593,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "mypy" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -610,8 +612,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.0" }, { name = "mypy", specifier = ">=1.19.1" }, { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=8.0.0" }, { name = "ruff", specifier = ">=0.15.2" }, ] @@ -838,6 +842,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1530,6 +1543,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1663,6 +1685,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/7f/c803c39fa76fe055bc4154fb6e897185ad21946820a2227283e0a20eeb35/pygeoif-1.6.0-py3-none-any.whl", hash = "sha256:02f84807dadbaf1941c4bb2a9ef1ebac99b1b0404597d2602efdbb58910c69c9", size = 27976, upload-time = "2025-10-01T10:02:12.19Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pymeeus" version = "0.5.12" @@ -1796,6 +1827,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/b4/a9430e72bfc3c458e1fcf8363890994e483052ab052ed93912be4e5b32c8/pystac-1.14.3-py3-none-any.whl", hash = "sha256:2f60005f521d541fb801428307098f223c14697b3faf4d2f0209afb6a43f39e5", size = 208506, upload-time = "2026-01-09T12:38:40.721Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 19d7aa23cb6b998d7003e4c84004cb201f52d316 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:04:37 +0100 Subject: [PATCH 09/36] ci: add GitHub Actions workflow for lint and test --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a1de2d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync --group dev + + - name: Run linting + run: make lint + + - name: Run tests + run: make test From 18b19f5e25aaa2e522c80a238bab54cdb8359d43 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:05:18 +0100 Subject: [PATCH 10/36] chore: add .python-version pinned to 3.13 --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 From 71c5bc164ddb9058c91b60774966f5b147ac80b4 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:07:18 +0100 Subject: [PATCH 11/36] fix(ci): set PYGEOAPI_CONFIG and PYGEOAPI_OPENAPI env vars --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a1de2d..da948e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,12 @@ on: jobs: lint-and-test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 + - name: Create .env from example + run: cp .env.example .env + - name: Install uv uses: astral-sh/setup-uv@v4 with: From 5cd339dfdc22af1356d68708f32dec26654df4b8 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:11:09 +0100 Subject: [PATCH 12/36] feat: add /health endpoint for Docker container health checks --- src/eo_api/routers/root.py | 8 +++++++- src/eo_api/schemas.py | 16 ++++++++++++++++ tests/test_root.py | 13 ++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/eo_api/routers/root.py b/src/eo_api/routers/root.py index 292d95e..2b51500 100644 --- a/src/eo_api/routers/root.py +++ b/src/eo_api/routers/root.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from eo_api.schemas import StatusMessage +from eo_api.schemas import HealthStatus, Status, StatusMessage router = APIRouter() @@ -11,3 +11,9 @@ def read_index() -> StatusMessage: """Return a welcome message for the root endpoint.""" return StatusMessage(message="Welcome to DHIS2 EO API") + + +@router.get("/health") +def health() -> HealthStatus: + """Return health status for container health checks.""" + return HealthStatus(status=Status.HEALTHY) diff --git a/src/eo_api/schemas.py b/src/eo_api/schemas.py index 4052ca2..184f9da 100644 --- a/src/eo_api/schemas.py +++ b/src/eo_api/schemas.py @@ -1,5 +1,7 @@ """Pydantic response models.""" +from enum import StrEnum + from pydantic import BaseModel @@ -7,3 +9,17 @@ class StatusMessage(BaseModel): """Simple status message response.""" message: str + + +class Status(StrEnum): + """Health check status values.""" + + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + + +class HealthStatus(BaseModel): + """Health check response.""" + + status: Status diff --git a/tests/test_root.py b/tests/test_root.py index bc3a252..e45752c 100644 --- a/tests/test_root.py +++ b/tests/test_root.py @@ -1,4 +1,4 @@ -from eo_api.schemas import StatusMessage +from eo_api.schemas import HealthStatus, StatusMessage def test_root_returns_200(client): @@ -10,3 +10,14 @@ def test_root_returns_welcome_message(client): response = client.get("/") result = StatusMessage.model_validate(response.json()) assert result.message == "Welcome to DHIS2 EO API" + + +def test_health_returns_200(client): + response = client.get("/health") + assert response.status_code == 200 + + +def test_health_returns_healthy_status(client): + response = client.get("/health") + result = HealthStatus.model_validate(response.json()) + assert result.status == "healthy" From d39085a29850216b2be28cd4c43825ea671e81fa Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:29:51 +0100 Subject: [PATCH 13/36] feat: add Dockerfile and docker Makefile targets Add containerized deployment with uv base image, non-root user, and healthcheck. Include .dockerignore and docker-build/docker-run Make targets. --- .dockerignore | 11 +++++++++++ Dockerfile | 30 ++++++++++++++++++++++++++++++ Makefile | 8 +++++++- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3e6abe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +.git/ +__pycache__/ +.env +*.egg-info/ +.DS_Store +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ +tests/ +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c989a1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM ghcr.io/astral-sh/uv:0.10-python3.13-trixie-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git curl \ + build-essential cython3 \ + libgeos-dev libproj-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN groupadd --system eo && useradd --system --gid eo --create-home eo + +WORKDIR /app + +COPY pyproject.toml uv.lock .python-version ./ +COPY src/ src/ + +RUN uv sync --frozen --no-dev + +COPY pygeoapi-config.yml pygeoapi-openapi.yml ./ + +ENV PYGEOAPI_CONFIG=/app/pygeoapi-config.yml +ENV PYGEOAPI_OPENAPI=/app/pygeoapi-openapi.yml +ENV PORT=8000 + +USER eo + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT}/health || exit 1 + +CMD ["/app/.venv/bin/uvicorn", "eo_api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 80af839..422d359 100644 --- a/Makefile +++ b/Makefile @@ -14,4 +14,10 @@ lint: ## Run ruff linting and formatting (autofix) uv run ruff format . test: ## Run tests with pytest - uv run pytest tests/ \ No newline at end of file + uv run pytest tests/ + +docker-build: ## Build Docker image + docker build -t eo-api . + +docker-run: ## Run Docker container + docker run --rm --env-file .env -p 8000:8000 eo-api \ No newline at end of file From 30f0c1c982e3eef7f87aa8f1e5d100644b0687be Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:43:08 +0100 Subject: [PATCH 14/36] feat: add /info endpoint and tag root routes as "System" Add __version__ to eo_api package, an AppInfo schema, and a GET /info endpoint returning app/Python/titiler/pygeoapi/uvicorn versions. Tag all root router endpoints with "System" for OpenAPI grouping. --- src/eo_api/__init__.py | 6 ++++++ src/eo_api/routers/root.py | 19 +++++++++++++++++-- src/eo_api/schemas.py | 10 ++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/eo_api/__init__.py b/src/eo_api/__init__.py index e69de29..b18cf39 100644 --- a/src/eo_api/__init__.py +++ b/src/eo_api/__init__.py @@ -0,0 +1,6 @@ +try: + from importlib.metadata import version as _get_version + + __version__ = _get_version("eo-api") +except Exception: + __version__ = "unknown" diff --git a/src/eo_api/routers/root.py b/src/eo_api/routers/root.py index 2b51500..0d4cc5f 100644 --- a/src/eo_api/routers/root.py +++ b/src/eo_api/routers/root.py @@ -1,10 +1,13 @@ """Root API endpoints.""" +import sys +from importlib.metadata import version + from fastapi import APIRouter -from eo_api.schemas import HealthStatus, Status, StatusMessage +from eo_api.schemas import AppInfo, HealthStatus, Status, StatusMessage -router = APIRouter() +router = APIRouter(tags=["System"]) @router.get("/") @@ -17,3 +20,15 @@ def read_index() -> StatusMessage: def health() -> HealthStatus: """Return health status for container health checks.""" return HealthStatus(status=Status.HEALTHY) + + +@router.get("/info") +def info() -> AppInfo: + """Return application version and environment info.""" + return AppInfo( + app_version=version("eo-api"), + python_version=sys.version, + titiler_version=version("titiler.core"), + pygeoapi_version=version("pygeoapi"), + uvicorn_version=version("uvicorn"), + ) diff --git a/src/eo_api/schemas.py b/src/eo_api/schemas.py index 184f9da..39c0402 100644 --- a/src/eo_api/schemas.py +++ b/src/eo_api/schemas.py @@ -23,3 +23,13 @@ class HealthStatus(BaseModel): """Health check response.""" status: Status + + +class AppInfo(BaseModel): + """Application version and environment info.""" + + app_version: str + python_version: str + titiler_version: str + pygeoapi_version: str + uvicorn_version: str From 4dec425d4cd58bdf6cca1bb61a7a74dfed2d9db7 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:46:10 +0100 Subject: [PATCH 15/36] fix(docker): use PORT env var in CMD instead of hardcoded 8000 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c989a1c..5855c49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ USER eo HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD curl -f http://localhost:${PORT}/health || exit 1 -CMD ["/app/.venv/bin/uvicorn", "eo_api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD /app/.venv/bin/uvicorn eo_api.main:app --host 0.0.0.0 --port ${PORT} From ba1483a9b2fcaacad698d1989a09adf3213cd304 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:47:45 +0100 Subject: [PATCH 16/36] feat(docker): add compose.yml and update Makefile targets Replace docker-build/docker-run with docker-up/docker-down targets that use docker compose for declarative container management. --- Makefile | 8 ++++---- compose.yml | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 compose.yml diff --git a/Makefile b/Makefile index 422d359..2883f69 100644 --- a/Makefile +++ b/Makefile @@ -16,8 +16,8 @@ lint: ## Run ruff linting and formatting (autofix) test: ## Run tests with pytest uv run pytest tests/ -docker-build: ## Build Docker image - docker build -t eo-api . +docker-up: ## Start container with docker compose + docker compose up --build -d -docker-run: ## Run Docker container - docker run --rm --env-file .env -p 8000:8000 eo-api \ No newline at end of file +docker-down: ## Stop container with docker compose + docker compose down \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..1614ddf --- /dev/null +++ b/compose.yml @@ -0,0 +1,7 @@ +services: + api: + build: . + env_file: .env + ports: + - "${PORT:-8000}:${PORT:-8000}" + restart: unless-stopped From b8cc98bd9c0740442e9b59e587e6d264022c8041 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 19:51:07 +0100 Subject: [PATCH 17/36] fix(docker): add init: true for proper signal forwarding Shell-form CMD runs under /bin/sh which doesn't forward SIGTERM, causing exit code 137 on Ctrl-C. Tini as PID 1 fixes this. --- compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/compose.yml b/compose.yml index 1614ddf..ca0fe61 100644 --- a/compose.yml +++ b/compose.yml @@ -4,4 +4,5 @@ services: env_file: .env ports: - "${PORT:-8000}:${PORT:-8000}" + init: true restart: unless-stopped From 9d50758fc9c95384b2ffc7f6e754893666b01e4a Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:08:39 +0100 Subject: [PATCH 18/36] docs: document pygeoapi configuration system in ogcapi.py --- src/eo_api/routers/ogcapi.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/eo_api/routers/ogcapi.py b/src/eo_api/routers/ogcapi.py index aa16781..ce54b71 100644 --- a/src/eo_api/routers/ogcapi.py +++ b/src/eo_api/routers/ogcapi.py @@ -1,5 +1,30 @@ -"""OGC API endpoints (pygeoapi).""" +"""OGC API endpoints (pygeoapi). + +Unlike titiler (configured in Python), pygeoapi is almost entirely +YAML-config-driven. The config file is located via the ``PYGEOAPI_CONFIG`` +environment variable and controls: + +- **resources** -- datasets exposed as OGC API collections. + Each resource declares a type (feature, coverage, map, process) and a + provider that handles the backend I/O (e.g. Elasticsearch, PostGIS, + rasterio, xarray). +- **server settings** -- gzip compression, CORS headers, response limits, + language negotiation, and the optional admin API. +- **metadata** -- service identification, contact info, and license. +- **API rules** -- URL path encoding, property inclusion/exclusion, and + custom API behaviour overrides. + +Adding or changing datasets therefore means editing the YAML file, not +this module. + +References: +---------- +- Configuration guide: https://docs.pygeoapi.io/en/latest/configuration.html +- Data publishing: https://docs.pygeoapi.io/en/latest/data-publishing/ +""" from pygeoapi.starlette_app import APP as pygeoapi_app +# pygeoapi exposes a ready-made Starlette app; we re-export it so the +# main application can mount it with app.mount(). app = pygeoapi_app From 421135fedd57f6d661a2c0ac9801241b8d6b3541 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:21:22 +0100 Subject: [PATCH 19/36] docs: add OGC API and pygeoapi reference guide --- docs/ogcapi.md | 213 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/ogcapi.md diff --git a/docs/ogcapi.md b/docs/ogcapi.md new file mode 100644 index 0000000..318f694 --- /dev/null +++ b/docs/ogcapi.md @@ -0,0 +1,213 @@ +# OGC API and pygeoapi + +## OGC API overview + +OGC API is a family of standards from the [Open Geospatial Consortium](https://www.ogc.org/) that define RESTful interfaces for geospatial data. Each standard covers a specific data type or interaction pattern: + +| Standard | Purpose | +|---|---| +| [Features](https://ogcapi.ogc.org/features/) | Vector feature access (GeoJSON, etc.) | +| [Coverages](https://ogcapi.ogc.org/coverages/) | Gridded / raster data | +| [EDR](https://ogcapi.ogc.org/edr/) | Environmental Data Retrieval (point, trajectory, corridor queries) | +| [Processes](https://ogcapi.ogc.org/processes/) | Server-side processing / workflows | +| [Maps](https://ogcapi.ogc.org/maps/) | Rendered map images | +| [Tiles](https://ogcapi.ogc.org/tiles/) | Tiled data (vector and map tiles) | +| [Records](https://ogcapi.ogc.org/records/) | Catalogue / metadata search | + +All standards share a common core: JSON/HTML responses, OpenAPI-described endpoints, and content negotiation. The full specification catalogue is at . + +## pygeoapi + +[pygeoapi](https://pygeoapi.io) is a Python server that implements the OGC API standards listed above. It is the OGC Reference Implementation for OGC API - Features. + +In this project pygeoapi is mounted as a sub-application at `/ogcapi`. The integration is minimal -- a single re-export in `src/eo_api/routers/ogcapi.py`: + +```python +from pygeoapi.starlette_app import APP as pygeoapi_app + +app = pygeoapi_app # mounted by the main FastAPI app +``` + +All dataset and behaviour configuration happens in YAML, not Python code. + +- pygeoapi docs: +- Source: + +## Configuration + +pygeoapi is configured through a single YAML file whose path is set by the `PYGEOAPI_CONFIG` environment variable. The repo ships a default config at `pygeoapi-config.yml`. + +### Top-level sections + +```yaml +server: # host, port, URL, limits, CORS, languages, admin toggle +logging: # log level and optional log file +metadata: # service identification, contact, license +resources: # datasets and processes exposed by the API +``` + +### `server` + +Controls runtime behaviour -- bind address, public URL, response encoding, language negotiation, pagination limits, and the optional admin API. + +```yaml +server: + bind: + host: 127.0.0.1 + port: 5000 + url: http://127.0.0.1:8000/ogcapi + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + languages: + - en-US + - fr-CA + limits: + default_items: 20 + max_items: 50 + admin: false +``` + +### `metadata` + +Service-level identification, contact details, and license. Supports multilingual values. + +```yaml +metadata: + identification: + title: + en: DHIS2 EO API + description: + en: OGC API compliant geospatial data API + provider: + name: DHIS2 EO API + url: https://dhis2.org + contact: + name: DHIS2 Climate Team + email: climate@dhis2.org +``` + +### `resources` + +Each key under `resources` defines a collection or process. A collection needs at minimum a `type`, `title`, `description`, `extents`, and one or more `providers`. + +```yaml +resources: + lakes: + type: collection + title: Large Lakes + description: lakes of the world, public domain + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: GeoJSON + data: tests/data/ne_110m_lakes.geojson + id_field: id +``` + +Full configuration reference: + +## Resource types + +The `type` field on a provider determines which OGC API standard the collection exposes. + +| Provider type | OGC API standard | Description | +|---|---|---| +| `feature` | Features | Vector data (points, lines, polygons). Backends include CSV, GeoJSON, PostGIS, Elasticsearch, and others. | +| `coverage` | Coverages | Gridded / raster data. Backends include rasterio, xarray, and S3-hosted COGs. | +| `map` | Maps | Rendered map images, typically proxied from an upstream WMS via `WMSFacade`. | +| `process` | Processes | Server-side processing tasks. Defined by a `processor` rather than a `providers` list. | + +A single collection can have multiple providers (e.g. both `feature` and `tile` on the same resource). + +## Plugin system + +pygeoapi uses a plugin architecture so that new data backends, output formats, and processing tasks can be added without modifying the core. + +### Plugin categories + +| Category | Base class | Purpose | +|---|---|---| +| **provider** | `pygeoapi.provider.base.BaseProvider` | Data access (read features, coverages, tiles, etc.) | +| **formatter** | `pygeoapi.formatter.base.BaseFormatter` | Output format conversion (e.g. CSV export) | +| **process** | `pygeoapi.process.base.BaseProcessor` | Server-side processing logic | +| **process_manager** | `pygeoapi.process.manager.base.BaseManager` | Job tracking and async execution | + +### How loading works + +In the YAML config the `name` field on a provider or processor identifies the plugin. pygeoapi resolves it in two ways: + +1. **Short name** -- a built-in alias registered in pygeoapi's plugin registry (e.g. `GeoJSON`, `CSV`, `rasterio`, `HelloWorld`). +2. **Dotted Python path** -- a fully-qualified class name for custom plugins (e.g. `mypackage.providers.MyProvider`). + +### Creating a custom plugin + +A custom provider needs to subclass the appropriate base class and implement the required methods. + +```python +from pygeoapi.provider.base import BaseProvider + + +class MyProvider(BaseProvider): + """Custom feature provider.""" + + def __init__(self, provider_def): + super().__init__(provider_def) + # provider_def contains the YAML provider block + + def get(self, identifier, **kwargs): + # Return a single feature by ID + ... + + def query(self, **kwargs): + # Return a FeatureCollection matching the query parameters + ... +``` + +Reference it in the config by dotted path: + +```yaml +providers: + - type: feature + name: mypackage.providers.MyProvider + data: /path/to/data +``` + +For processes, subclass `BaseProcessor` and set `PROCESS_METADATA` as a class-level dict describing inputs and outputs: + +```python +from pygeoapi.process.base import BaseProcessor + +PROCESS_METADATA = { + "version": "0.1.0", + "id": "my-process", + "title": "My Process", + "inputs": { ... }, + "outputs": { ... }, +} + + +class MyProcessor(BaseProcessor): + def __init__(self, processor_def): + super().__init__(processor_def, PROCESS_METADATA) + + def execute(self, data): + # Process input data and return results + ... +``` + +## References + +- OGC API standards catalogue: +- OGC API - Features spec: +- OGC API - Coverages spec: +- OGC API - EDR spec: +- OGC API - Processes spec: +- pygeoapi documentation: +- pygeoapi configuration guide: +- pygeoapi data publishing guide: +- pygeoapi plugins: +- Community plugins wiki: +- pygeoapi source: From dc0b129eb73f02015c70947cebe4f0eb4bba6a4e Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:30:12 +0100 Subject: [PATCH 20/36] refactor: move pygeoapi plugins into src layout and fix lint Move plugins/ from repo root into src/eo_api/routers/ogcapi/plugins/, convert ogcapi.py into a package, rename dhis2orgUnits.py to snake_case, fix mutable default args, add missing self param, and add docstrings. Update pygeoapi-config.yml provider paths accordingly. --- plugins/dhis2eo.py | 39 ------------ plugins/dhis2orgUnits.py | 47 -------------- pygeoapi-config.yml | 6 +- .../routers/{ogcapi.py => ogcapi/__init__.py} | 0 .../routers/ogcapi/plugins}/__init__.py | 0 .../routers/ogcapi/plugins/dhis2_org_units.py | 62 +++++++++++++++++++ src/eo_api/routers/ogcapi/plugins/dhis2eo.py | 41 ++++++++++++ 7 files changed, 106 insertions(+), 89 deletions(-) delete mode 100644 plugins/dhis2eo.py delete mode 100644 plugins/dhis2orgUnits.py rename src/eo_api/routers/{ogcapi.py => ogcapi/__init__.py} (100%) rename {plugins => src/eo_api/routers/ogcapi/plugins}/__init__.py (100%) create mode 100644 src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py create mode 100644 src/eo_api/routers/ogcapi/plugins/dhis2eo.py diff --git a/plugins/dhis2eo.py b/plugins/dhis2eo.py deleted file mode 100644 index c148a43..0000000 --- a/plugins/dhis2eo.py +++ /dev/null @@ -1,39 +0,0 @@ -from pygeoapi.provider.base_edr import BaseEDRProvider - - -class DHIS2EOProvider(BaseEDRProvider): - """Minimal EDR provider example.""" - - def get_fields(self): - return { - 'value': { - 'type': 'number', - 'title': 'Value', - 'x-ogc-unit': 'mm/day', - } - } - - def position(self, **kwargs): - return { - 'type': 'Coverage', - 'domain': { - 'type': 'Domain', - 'domainType': 'Point', - 'axes': { - 'Long': {'values': [0.0]}, - 'Lat': {'values': [0.0]}, - }, - }, - 'parameters': { - 'value': {'type': 'Parameter'} - }, - 'ranges': { - 'value': { - 'type': 'NdArray', - 'dataType': 'float', - 'axisNames': ['Long', 'Lat'], - 'shape': [1, 1], - 'values': [10.0], - } - }, - } \ No newline at end of file diff --git a/plugins/dhis2orgUnits.py b/plugins/dhis2orgUnits.py deleted file mode 100644 index 4d34e09..0000000 --- a/plugins/dhis2orgUnits.py +++ /dev/null @@ -1,47 +0,0 @@ -from pygeoapi.provider.base import BaseProvider - -class DHIS2OrgUnitsProvider(BaseProvider): - """DHIS2 Organization Units Provider""" - - def __init__(self, provider_def): - """Inherit from parent class""" - - super().__init__(provider_def) - - def get_fields(self): - - # open dat file and return fields and their datatypes - return { - 'field1': 'string', - 'field2': 'string' - } - - def query(self, offset=0, limit=10, resulttype='results', - bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, **kwargs): - - # optionally specify the output filename pygeoapi can use as part - # of the response (HTTP Content-Disposition header) - self.filename = 'my-cool-filename.dat' - - # open data file (self.data) and process, return - return { - 'type': 'FeatureCollection', - 'features': [{ - 'type': 'Feature', - 'id': '371', - 'geometry': { - 'type': 'Point', - 'coordinates': [ -75, 45 ] - }, - 'properties': { - 'stn_id': '35', - 'datetime': '2001-10-30T14:24:55Z', - 'value': '89.9' - } - }] - } - - def get_schema(): - # return a `dict` of a JSON schema (inline or reference) - return ('application/geo+json', {'$ref': 'https://geojson.org/schema/Feature.json'}) \ No newline at end of file diff --git a/pygeoapi-config.yml b/pygeoapi-config.yml index d4222cc..e6e2014 100644 --- a/pygeoapi-config.yml +++ b/pygeoapi-config.yml @@ -99,7 +99,7 @@ resources: crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 providers: - type: feature - name: plugins.dhis2orgUnits.DHIS2OrgUnitsProvider + name: eo_api.routers.ogcapi.plugins.dhis2_org_units.DHIS2OrgUnitsProvider data: tests/data/ id_field: id @@ -122,7 +122,7 @@ resources: hreflang: en-EN providers: - type: edr - name: plugins.dhis2eo.DHIS2EOProvider + name: eo_api.routers.ogcapi.plugins.dhis2eo.DHIS2EOProvider data: tests/data/ era5-land: @@ -143,7 +143,7 @@ resources: hreflang: en-EN providers: - type: edr - name: plugins.dhis2eo.DHIS2EOProvider + name: eo_api.routers.ogcapi.plugins.dhis2eo.DHIS2EOProvider data: tests/data/ obs: diff --git a/src/eo_api/routers/ogcapi.py b/src/eo_api/routers/ogcapi/__init__.py similarity index 100% rename from src/eo_api/routers/ogcapi.py rename to src/eo_api/routers/ogcapi/__init__.py diff --git a/plugins/__init__.py b/src/eo_api/routers/ogcapi/plugins/__init__.py similarity index 100% rename from plugins/__init__.py rename to src/eo_api/routers/ogcapi/plugins/__init__.py diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py new file mode 100644 index 0000000..fa20f30 --- /dev/null +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -0,0 +1,62 @@ +"""DHIS2 Organization Units feature provider for pygeoapi.""" + +from pygeoapi.provider.base import BaseProvider + + +class DHIS2OrgUnitsProvider(BaseProvider): + """DHIS2 Organization Units Provider.""" + + def __init__(self, provider_def): + """Inherit from parent class.""" + super().__init__(provider_def) + + def get_fields(self): + """Return fields and their datatypes.""" + return {"field1": "string", "field2": "string"} + + def query( + self, + offset=0, + limit=10, + resulttype="results", + bbox=None, + datetime_=None, + properties=None, + sortby=None, + select_properties=None, + skip_geometry=False, + **kwargs, + ): + """Return feature collection matching the query parameters.""" + if bbox is None: + bbox = [] + if properties is None: + properties = [] + if sortby is None: + sortby = [] + if select_properties is None: + select_properties = [] + + # optionally specify the output filename pygeoapi can use as part + # of the response (HTTP Content-Disposition header) + self.filename = "my-cool-filename.dat" + + # open data file (self.data) and process, return + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "371", + "geometry": {"type": "Point", "coordinates": [-75, 45]}, + "properties": {"stn_id": "35", "datetime": "2001-10-30T14:24:55Z", "value": "89.9"}, + } + ], + } + + def get_schema(self): + """Return a JSON schema for the provider.""" + return ( + "application/geo+json", + {"$ref": "https://geojson.org/schema/Feature.json"}, + ) diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2eo.py b/src/eo_api/routers/ogcapi/plugins/dhis2eo.py new file mode 100644 index 0000000..e898260 --- /dev/null +++ b/src/eo_api/routers/ogcapi/plugins/dhis2eo.py @@ -0,0 +1,41 @@ +"""DHIS2 EO EDR provider for pygeoapi.""" + +from pygeoapi.provider.base_edr import BaseEDRProvider + + +class DHIS2EOProvider(BaseEDRProvider): + """Minimal EDR provider example.""" + + def get_fields(self): + """Return available fields.""" + return { + "value": { + "type": "number", + "title": "Value", + "x-ogc-unit": "mm/day", + } + } + + def position(self, **kwargs): + """Return coverage data for a point position.""" + return { + "type": "Coverage", + "domain": { + "type": "Domain", + "domainType": "Point", + "axes": { + "Long": {"values": [0.0]}, + "Lat": {"values": [0.0]}, + }, + }, + "parameters": {"value": {"type": "Parameter"}}, + "ranges": { + "value": { + "type": "NdArray", + "dataType": "float", + "axisNames": ["Long", "Lat"], + "shape": [1, 1], + "values": [10.0], + } + }, + } From afec0e935058ef81b2708f5aef006f8a66d543f0 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:35:57 +0100 Subject: [PATCH 21/36] refactor: add type annotations to pygeoapi plugin files --- .../routers/ogcapi/plugins/dhis2_org_units.py | 32 ++++++++++--------- src/eo_api/routers/ogcapi/plugins/dhis2eo.py | 6 ++-- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index fa20f30..b6ac65a 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -1,32 +1,34 @@ """DHIS2 Organization Units feature provider for pygeoapi.""" -from pygeoapi.provider.base import BaseProvider +from typing import Any + +from pygeoapi.provider.base import BaseProvider, SchemaType class DHIS2OrgUnitsProvider(BaseProvider): """DHIS2 Organization Units Provider.""" - def __init__(self, provider_def): + def __init__(self, provider_def: dict[str, Any]) -> None: """Inherit from parent class.""" super().__init__(provider_def) - def get_fields(self): + def get_fields(self) -> dict[str, str]: """Return fields and their datatypes.""" return {"field1": "string", "field2": "string"} def query( self, - offset=0, - limit=10, - resulttype="results", - bbox=None, - datetime_=None, - properties=None, - sortby=None, - select_properties=None, - skip_geometry=False, - **kwargs, - ): + offset: int = 0, + limit: int = 10, + resulttype: str = "results", + bbox: list[float] | None = None, + datetime_: str | None = None, + properties: list[str] | None = None, + sortby: list[str] | None = None, + select_properties: list[str] | None = None, + skip_geometry: bool = False, + **kwargs: Any, + ) -> dict[str, Any]: """Return feature collection matching the query parameters.""" if bbox is None: bbox = [] @@ -54,7 +56,7 @@ def query( ], } - def get_schema(self): + def get_schema(self, schema_type: SchemaType = SchemaType.item) -> tuple[str, dict[str, Any]]: """Return a JSON schema for the provider.""" return ( "application/geo+json", diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2eo.py b/src/eo_api/routers/ogcapi/plugins/dhis2eo.py index e898260..3153f24 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2eo.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2eo.py @@ -1,12 +1,14 @@ """DHIS2 EO EDR provider for pygeoapi.""" +from typing import Any + from pygeoapi.provider.base_edr import BaseEDRProvider class DHIS2EOProvider(BaseEDRProvider): """Minimal EDR provider example.""" - def get_fields(self): + def get_fields(self) -> dict[str, dict[str, Any]]: """Return available fields.""" return { "value": { @@ -16,7 +18,7 @@ def get_fields(self): } } - def position(self, **kwargs): + def position(self, **kwargs: Any) -> dict[str, Any]: """Return coverage data for a point position.""" return { "type": "Coverage", From 11cc3be9b8767cb458ee00b72091e73b58030125 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:37:27 +0100 Subject: [PATCH 22/36] refactor: replace docker-up/down with docker-build and docker-run targets --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index c4467de..026003e 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,8 @@ test: ## Run tests with pytest openapi: ## Generate pygeoapi OpenAPI spec PYTHONPATH="$(PWD)" uv run pygeoapi openapi generate ./pygeoapi-config.yml > pygeoapi-openapi.yml -docker-up: ## Start container with docker compose - docker compose up --build -d +docker-build: ## Full rebuild with docker compose + docker compose build --no-cache -docker-down: ## Stop container with docker compose - docker compose down +docker-run: ## Start containers with docker compose + docker compose up From ec7983d6ab15a71a87da1d5bec22180ce67ca31f Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:45:25 +0100 Subject: [PATCH 23/36] fix: implement get() method in DHIS2OrgUnitsProvider GET /ogcapi/collections/dhis2-org-units/items/{id} returned 500 because the provider didn't override BaseProvider.get(), which raises NotImplementedError. Add a stub get() that returns a single hardcoded GeoJSON Feature, consistent with the existing query() stub. --- src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index b6ac65a..6596a48 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -16,6 +16,15 @@ def get_fields(self) -> dict[str, str]: """Return fields and their datatypes.""" return {"field1": "string", "field2": "string"} + def get(self, identifier: str, **kwargs: Any) -> dict[str, Any]: + """Return a single feature by identifier.""" + return { + "type": "Feature", + "id": identifier, + "geometry": {"type": "Point", "coordinates": [-75, 45]}, + "properties": {"stn_id": "35", "datetime": "2001-10-30T14:24:55Z", "value": "89.9"}, + } + def query( self, offset: int = 0, From 53b46f2f35e9fb78a4c19f4a63608153199fdbdf Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 20:54:34 +0100 Subject: [PATCH 24/36] feat: fetch real DHIS2 org units via httpx with Pydantic models Replace stub data in DHIS2OrgUnitsProvider with live API calls to the DHIS2 play server. Use Pydantic for response validation and geojson-pydantic for GeoJSON output. Fields: name, code, shortName, level, openingDate, geometry. --- pyproject.toml | 2 + .../routers/ogcapi/plugins/dhis2_org_units.py | 107 ++++++++++++------ uv.lock | 4 + 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7372960..28f601d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "pygeoapi>=0.22.0", "dhis2eo @ git+https://github.com/dhis2/dhis2eo.git@v1.1.0", "dhis2-client @ git+https://github.com/dhis2/dhis2-python-client.git@V0.3.0", + "geojson-pydantic>=2.1.0", + "httpx>=0.28.1", ] [tool.ruff] diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index 6596a48..4482b8a 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -1,9 +1,57 @@ """DHIS2 Organization Units feature provider for pygeoapi.""" +from datetime import datetime from typing import Any +import httpx +from geojson_pydantic import Feature, FeatureCollection +from geojson_pydantic.geometries import Geometry +from pydantic import BaseModel from pygeoapi.provider.base import BaseProvider, SchemaType +DHIS2_BASE_URL = "https://play.im.dhis2.org/dev/api" +DHIS2_AUTH = ("admin", "district") +DHIS2_FIELDS = "id,code,name,shortName,level,openingDate,geometry" + + +class DHIS2OrgUnit(BaseModel): + """Organisation unit as returned by the DHIS2 API.""" + + id: str + name: str | None = None + code: str | None = None + shortName: str | None = None + level: int | None = None + openingDate: datetime | None = None + geometry: Geometry | None = None + + +def _fetch_org_units() -> list[DHIS2OrgUnit]: + """Fetch all organisation units from the DHIS2 API.""" + response = httpx.get( + f"{DHIS2_BASE_URL}/organisationUnits", + params={"paging": "false", "fields": DHIS2_FIELDS}, + auth=DHIS2_AUTH, + ) + response.raise_for_status() + return [DHIS2OrgUnit.model_validate(ou) for ou in response.json()["organisationUnits"]] + + +def _org_unit_to_feature(org_unit: DHIS2OrgUnit) -> Feature: + """Convert a DHIS2 org unit to a GeoJSON Feature.""" + return Feature( + type="Feature", + id=org_unit.id, + geometry=org_unit.geometry, + properties={ + "name": org_unit.name, + "code": org_unit.code, + "shortName": org_unit.shortName, + "level": org_unit.level, + "openingDate": org_unit.openingDate.isoformat() if org_unit.openingDate else None, + }, + ) + class DHIS2OrgUnitsProvider(BaseProvider): """DHIS2 Organization Units Provider.""" @@ -14,16 +62,24 @@ def __init__(self, provider_def: dict[str, Any]) -> None: def get_fields(self) -> dict[str, str]: """Return fields and their datatypes.""" - return {"field1": "string", "field2": "string"} + return { + "name": "string", + "code": "string", + "shortName": "string", + "level": "integer", + "openingDate": "string", + } def get(self, identifier: str, **kwargs: Any) -> dict[str, Any]: """Return a single feature by identifier.""" - return { - "type": "Feature", - "id": identifier, - "geometry": {"type": "Point", "coordinates": [-75, 45]}, - "properties": {"stn_id": "35", "datetime": "2001-10-30T14:24:55Z", "value": "89.9"}, - } + response = httpx.get( + f"{DHIS2_BASE_URL}/organisationUnits/{identifier}", + params={"fields": DHIS2_FIELDS}, + auth=DHIS2_AUTH, + ) + response.raise_for_status() + org_unit = DHIS2OrgUnit.model_validate(response.json()) + return _org_unit_to_feature(org_unit).model_dump() def query( self, @@ -39,31 +95,18 @@ def query( **kwargs: Any, ) -> dict[str, Any]: """Return feature collection matching the query parameters.""" - if bbox is None: - bbox = [] - if properties is None: - properties = [] - if sortby is None: - sortby = [] - if select_properties is None: - select_properties = [] - - # optionally specify the output filename pygeoapi can use as part - # of the response (HTTP Content-Disposition header) - self.filename = "my-cool-filename.dat" - - # open data file (self.data) and process, return - return { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "id": "371", - "geometry": {"type": "Point", "coordinates": [-75, 45]}, - "properties": {"stn_id": "35", "datetime": "2001-10-30T14:24:55Z", "value": "89.9"}, - } - ], - } + org_units = _fetch_org_units() + number_matched = len(org_units) + page = org_units[offset : offset + limit] + + fc = FeatureCollection( + type="FeatureCollection", + features=[_org_unit_to_feature(ou) for ou in page], + ) + result = fc.model_dump() + result["numberMatched"] = number_matched + result["numberReturned"] = len(page) + return result def get_schema(self, schema_type: SchemaType = SchemaType.item) -> tuple[str, dict[str, Any]]: """Return a JSON schema for the provider.""" diff --git a/uv.lock b/uv.lock index 040313e..4af86c3 100644 --- a/uv.lock +++ b/uv.lock @@ -585,6 +585,8 @@ source = { editable = "." } dependencies = [ { name = "dhis2-client" }, { name = "dhis2eo" }, + { name = "geojson-pydantic" }, + { name = "httpx" }, { name = "pygeoapi" }, { name = "python-dotenv" }, { name = "titiler-core" }, @@ -604,6 +606,8 @@ dev = [ requires-dist = [ { name = "dhis2-client", git = "https://github.com/dhis2/dhis2-python-client.git?rev=V0.3.0" }, { name = "dhis2eo", git = "https://github.com/dhis2/dhis2eo.git?rev=v1.1.0" }, + { name = "geojson-pydantic", specifier = ">=2.1.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "pygeoapi", specifier = ">=0.22.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "titiler-core", specifier = ">=1.2.0" }, From fa86adc713234413a6034a2e61a7992681636e08 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 21:37:02 +0100 Subject: [PATCH 25/36] fix: return pygeoapi-compatible field dicts from get_fields() Add OrgUnitProperties Pydantic model and derive get_fields() from its JSON Schema so the /schema endpoint receives {"type": "string"} dicts instead of plain strings. Rename Makefile docker targets to start/restart. --- Makefile | 8 +-- .../routers/ogcapi/plugins/dhis2_org_units.py | 52 +++++++++++++------ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 026003e..a17fd10 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,8 @@ test: ## Run tests with pytest openapi: ## Generate pygeoapi OpenAPI spec PYTHONPATH="$(PWD)" uv run pygeoapi openapi generate ./pygeoapi-config.yml > pygeoapi-openapi.yml -docker-build: ## Full rebuild with docker compose - docker compose build --no-cache +start: ## Start the Docker stack (builds images first) + docker compose up --build -docker-run: ## Start containers with docker compose - docker compose up +restart: ## Tear down, rebuild, and start the Docker stack from scratch + docker compose down -v && docker compose build --no-cache && docker compose up diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index 4482b8a..c48a411 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -26,6 +26,30 @@ class DHIS2OrgUnit(BaseModel): geometry: Geometry | None = None +class OrgUnitProperties(BaseModel): + """Feature properties for a DHIS2 org unit.""" + + name: str | None = None + code: str | None = None + shortName: str | None = None + level: int | None = None + openingDate: str | None = None + + +def _schema_to_fields(model: type[BaseModel]) -> dict[str, dict[str, str]]: + """Convert a Pydantic model's JSON Schema to pygeoapi field definitions.""" + schema = model.model_json_schema() + fields = {} + for name, prop in schema["properties"].items(): + if "anyOf" in prop: + types = [t for t in prop["anyOf"] if t.get("type") != "null"] + field_type = types[0]["type"] if types else "string" + else: + field_type = prop.get("type", "string") + fields[name] = {"type": field_type} + return fields + + def _fetch_org_units() -> list[DHIS2OrgUnit]: """Fetch all organisation units from the DHIS2 API.""" response = httpx.get( @@ -39,17 +63,18 @@ def _fetch_org_units() -> list[DHIS2OrgUnit]: def _org_unit_to_feature(org_unit: DHIS2OrgUnit) -> Feature: """Convert a DHIS2 org unit to a GeoJSON Feature.""" + props = OrgUnitProperties( + name=org_unit.name, + code=org_unit.code, + shortName=org_unit.shortName, + level=org_unit.level, + openingDate=org_unit.openingDate.isoformat() if org_unit.openingDate else None, + ) return Feature( type="Feature", id=org_unit.id, geometry=org_unit.geometry, - properties={ - "name": org_unit.name, - "code": org_unit.code, - "shortName": org_unit.shortName, - "level": org_unit.level, - "openingDate": org_unit.openingDate.isoformat() if org_unit.openingDate else None, - }, + properties=props.model_dump(), ) @@ -59,16 +84,13 @@ class DHIS2OrgUnitsProvider(BaseProvider): def __init__(self, provider_def: dict[str, Any]) -> None: """Inherit from parent class.""" super().__init__(provider_def) + self.get_fields() - def get_fields(self) -> dict[str, str]: + def get_fields(self) -> dict[str, dict[str, str]]: """Return fields and their datatypes.""" - return { - "name": "string", - "code": "string", - "shortName": "string", - "level": "integer", - "openingDate": "string", - } + if not self._fields: + self._fields = _schema_to_fields(OrgUnitProperties) + return self._fields def get(self, identifier: str, **kwargs: Any) -> dict[str, Any]: """Return a single feature by identifier.""" From 96b58e5dbbe2bbc66c463c2f2ff14e2446ca935c Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 21:37:51 +0100 Subject: [PATCH 26/36] fix: include field titles in schema endpoint output --- .../routers/ogcapi/plugins/dhis2_org_units.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index c48a411..b540cdd 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -6,7 +6,7 @@ import httpx from geojson_pydantic import Feature, FeatureCollection from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel +from pydantic import BaseModel, Field from pygeoapi.provider.base import BaseProvider, SchemaType DHIS2_BASE_URL = "https://play.im.dhis2.org/dev/api" @@ -29,11 +29,11 @@ class DHIS2OrgUnit(BaseModel): class OrgUnitProperties(BaseModel): """Feature properties for a DHIS2 org unit.""" - name: str | None = None - code: str | None = None - shortName: str | None = None - level: int | None = None - openingDate: str | None = None + name: str | None = Field(None, title="Name") + code: str | None = Field(None, title="Code") + shortName: str | None = Field(None, title="Short name") + level: int | None = Field(None, title="Level") + openingDate: str | None = Field(None, title="Opening date") def _schema_to_fields(model: type[BaseModel]) -> dict[str, dict[str, str]]: @@ -46,7 +46,10 @@ def _schema_to_fields(model: type[BaseModel]) -> dict[str, dict[str, str]]: field_type = types[0]["type"] if types else "string" else: field_type = prop.get("type", "string") - fields[name] = {"type": field_type} + field_def: dict[str, str] = {"type": field_type} + if "title" in prop: + field_def["title"] = prop["title"] + fields[name] = field_def return fields From 1d3899c01154c6f2a220f1d8366e5adc23666577 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 22:21:31 +0100 Subject: [PATCH 27/36] refactor: move DHIS2 credentials to env vars and improve bbox handling Read DHIS2_BASE_URL, DHIS2_USERNAME, DHIS2_PASSWORD from environment variables instead of hardcoding them. Add follow_redirects=True to all httpx calls for compatibility with local DHIS2 instances. Compute collection bbox from level-1 org units (returning null if none have geometry) and per-feature bbox only for Polygon/MultiPolygon types. --- .env.example | 5 +- src/eo_api/routers/ogcapi/__init__.py | 17 ++++++ .../routers/ogcapi/plugins/dhis2_org_units.py | 54 ++++++++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index cd4a24c..30847dd 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ PYGEOAPI_CONFIG=pygeoapi-config.yml -PYGEOAPI_OPENAPI=pygeoapi-openapi.yml \ No newline at end of file +PYGEOAPI_OPENAPI=pygeoapi-openapi.yml +DHIS2_BASE_URL=https://play.im.dhis2.org/stable-2-42-4/api +DHIS2_USERNAME=admin +DHIS2_PASSWORD=district \ No newline at end of file diff --git a/src/eo_api/routers/ogcapi/__init__.py b/src/eo_api/routers/ogcapi/__init__.py index ce54b71..d74214b 100644 --- a/src/eo_api/routers/ogcapi/__init__.py +++ b/src/eo_api/routers/ogcapi/__init__.py @@ -23,7 +23,24 @@ - Data publishing: https://docs.pygeoapi.io/en/latest/data-publishing/ """ +import logging + from pygeoapi.starlette_app import APP as pygeoapi_app +from pygeoapi.starlette_app import CONFIG + +from eo_api.routers.ogcapi.plugins.dhis2_org_units import _fetch_bbox + +logger = logging.getLogger(__name__) + +try: + bbox = _fetch_bbox() + if bbox is not None: + CONFIG["resources"]["dhis2-org-units"]["extents"]["spatial"]["bbox"] = [bbox] + logger.info("DHIS2 org-units bbox set to %s", bbox) + else: + logger.info("No level-1 org unit geometry found, skipping bbox") +except Exception: + logger.warning("Failed to fetch DHIS2 bbox, using config default", exc_info=True) # pygeoapi exposes a ready-made Starlette app; we re-export it so the # main application can mount it with app.mount(). diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index b540cdd..3589a7c 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -1,5 +1,6 @@ """DHIS2 Organization Units feature provider for pygeoapi.""" +import os from datetime import datetime from typing import Any @@ -9,8 +10,8 @@ from pydantic import BaseModel, Field from pygeoapi.provider.base import BaseProvider, SchemaType -DHIS2_BASE_URL = "https://play.im.dhis2.org/dev/api" -DHIS2_AUTH = ("admin", "district") +DHIS2_BASE_URL = os.environ["DHIS2_BASE_URL"] +DHIS2_AUTH = (os.environ["DHIS2_USERNAME"], os.environ["DHIS2_PASSWORD"]) DHIS2_FIELDS = "id,code,name,shortName,level,openingDate,geometry" @@ -53,12 +54,56 @@ def _schema_to_fields(model: type[BaseModel]) -> dict[str, dict[str, str]]: return fields +def _flatten_coords(coords: list) -> list[list[float]]: + """Recursively flatten nested coordinate arrays into a list of [x, y] points.""" + if coords and isinstance(coords[0], (int, float)): + return [coords] + result = [] + for item in coords: + result.extend(_flatten_coords(item)) + return result + + +def _compute_bbox(geometry: Geometry) -> tuple[float, float, float, float]: + """Compute bounding box from a GeoJSON geometry.""" + coords = _flatten_coords(geometry.model_dump()["coordinates"]) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + return (min(xs), min(ys), max(xs), max(ys)) + + +def _fetch_bbox() -> list[float] | None: + """Compute bounding box from level-1 org unit geometries.""" + response = httpx.get( + f"{DHIS2_BASE_URL}/organisationUnits", + params={ + "paging": "false", + "fields": "geometry", + "filter": "level:eq:1", + }, + auth=DHIS2_AUTH, + follow_redirects=True, + ) + response.raise_for_status() + all_coords: list[list[float]] = [] + for ou in response.json()["organisationUnits"]: + geom = ou.get("geometry") + if geom and geom.get("coordinates"): + all_coords.extend(_flatten_coords(geom["coordinates"])) + if not all_coords: + return None + xs = [c[0] for c in all_coords] + ys = [c[1] for c in all_coords] + return [min(xs), min(ys), max(xs), max(ys)] + + def _fetch_org_units() -> list[DHIS2OrgUnit]: """Fetch all organisation units from the DHIS2 API.""" response = httpx.get( f"{DHIS2_BASE_URL}/organisationUnits", params={"paging": "false", "fields": DHIS2_FIELDS}, auth=DHIS2_AUTH, + follow_redirects=True, ) response.raise_for_status() return [DHIS2OrgUnit.model_validate(ou) for ou in response.json()["organisationUnits"]] @@ -73,11 +118,15 @@ def _org_unit_to_feature(org_unit: DHIS2OrgUnit) -> Feature: level=org_unit.level, openingDate=org_unit.openingDate.isoformat() if org_unit.openingDate else None, ) + bbox = None + if org_unit.geometry and org_unit.geometry.type in ("Polygon", "MultiPolygon"): + bbox = _compute_bbox(org_unit.geometry) return Feature( type="Feature", id=org_unit.id, geometry=org_unit.geometry, properties=props.model_dump(), + bbox=bbox, ) @@ -101,6 +150,7 @@ def get(self, identifier: str, **kwargs: Any) -> dict[str, Any]: f"{DHIS2_BASE_URL}/organisationUnits/{identifier}", params={"fields": DHIS2_FIELDS}, auth=DHIS2_AUTH, + follow_redirects=True, ) response.raise_for_status() org_unit = DHIS2OrgUnit.model_validate(response.json()) From 3d35dbc07f2de704b28c93f76ecd25e128024551 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Tue, 24 Feb 2026 22:29:19 +0100 Subject: [PATCH 28/36] docs: update README with all Makefile targets and DHIS2 env vars --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f78049a..ce64dec 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ Install dependencies (requires [uv](https://docs.astral.sh/uv/)): Environment variables are loaded automatically from `.env` (via `python-dotenv`). Copy `.env.example` to `.env` and adjust values as needed. +Key environment variables (used by the OGC API DHIS2 plugin): + +- `DHIS2_BASE_URL` -- DHIS2 API base URL (defaults to play server in `.env.example`) +- `DHIS2_USERNAME` -- DHIS2 username +- `DHIS2_PASSWORD` -- DHIS2 password + Start the app: `uv run uvicorn eo_api.main:app --reload` @@ -39,9 +45,13 @@ uvicorn eo_api.main:app --reload ### Makefile targets -- `make sync` — install dependencies with uv -- `make run` — start the app with uv -- `make lint` — run ruff linting and format checks +- `make sync` -- install dependencies with uv +- `make run` -- start the app with uvicorn +- `make lint` -- run ruff linting and format checks +- `make test` -- run tests with pytest +- `make openapi` -- generate pygeoapi OpenAPI spec +- `make start` -- start the Docker stack (builds images first) +- `make restart` -- tear down, rebuild, and start the Docker stack from scratch ### pygeoapi instructions From 3720b2c2c5d9c3bd6e77b2a711db721d9fe84241 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 00:05:54 +0100 Subject: [PATCH 29/36] refactor: auto-generate pygeoapi-openapi.yml and add DHIS2 CQL provider Drop committed pygeoapi-openapi.yml in favour of on-demand generation. The openapi Makefile target now sources .env and is a prerequisite of run, start, and restart. Also extract shared DHIS2 helpers into dhis2_common and add a CQL-capable org-units provider. --- .gitignore | 1 + Makefile | 9 +- pygeoapi-config.yml | 16 + pygeoapi-openapi.yml | 1352 ----------------- .../routers/ogcapi/plugins/dhis2_common.py | 117 ++ .../routers/ogcapi/plugins/dhis2_org_units.py | 127 +- .../ogcapi/plugins/dhis2_org_units_cql.py | 76 + 7 files changed, 231 insertions(+), 1467 deletions(-) delete mode 100644 pygeoapi-openapi.yml create mode 100644 src/eo_api/routers/ogcapi/plugins/dhis2_common.py create mode 100644 src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py diff --git a/.gitignore b/.gitignore index 67c3344..e654634 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .venv/ .env eo_api.egg-info/ +pygeoapi-openapi.yml diff --git a/Makefile b/Makefile index a17fd10..217d93d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ help: ## Show this help sync: ## Install dependencies with uv uv sync -run: ## Start the app with uvicorn +run: openapi ## Start the app with uvicorn uv run uvicorn eo_api.main:app --reload lint: ## Run ruff linting and formatting (autofix) @@ -17,10 +17,11 @@ test: ## Run tests with pytest uv run pytest tests/ openapi: ## Generate pygeoapi OpenAPI spec - PYTHONPATH="$(PWD)" uv run pygeoapi openapi generate ./pygeoapi-config.yml > pygeoapi-openapi.yml + @set -a && . ./.env && set +a && \ + PYTHONPATH="$(PWD)/src" uv run pygeoapi openapi generate ./pygeoapi-config.yml > pygeoapi-openapi.yml -start: ## Start the Docker stack (builds images first) +start: openapi ## Start the Docker stack (builds images first) docker compose up --build -restart: ## Tear down, rebuild, and start the Docker stack from scratch +restart: openapi ## Tear down, rebuild, and start the Docker stack from scratch docker compose down -v && docker compose build --no-cache && docker compose up diff --git a/pygeoapi-config.yml b/pygeoapi-config.yml index e6e2014..5f922bf 100644 --- a/pygeoapi-config.yml +++ b/pygeoapi-config.yml @@ -103,6 +103,22 @@ resources: data: tests/data/ id_field: id + dhis2-org-units-cql: + type: collection + title: DHIS2 organization units (CQL) + description: Get organization units from DHIS2 with CQL filter support + keywords: + - dhis2 + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: eo_api.routers.ogcapi.plugins.dhis2_org_units_cql.DHIS2OrgUnitsCqlProvider + data: tests/data/ + id_field: id + chirps-precipitation: type: collection title: CHIRPS v3 daily precipitation diff --git a/pygeoapi-openapi.yml b/pygeoapi-openapi.yml deleted file mode 100644 index ed37cf6..0000000 --- a/pygeoapi-openapi.yml +++ /dev/null @@ -1,1352 +0,0 @@ -components: - parameters: - bbox: - description: Only features that have a geometry that intersects the bounding - box are selected.The bounding box is provided as four or six numbers, depending - on whether the coordinate reference system includes a vertical axis (height - or depth). - explode: false - in: query - name: bbox - required: false - schema: - items: - type: number - maxItems: 6 - minItems: 4 - type: array - style: form - bbox-crs: - description: Indicates the coordinate reference system for the given bbox coordinates. - explode: false - in: query - name: bbox-crs - required: false - schema: - format: uri - type: string - style: form - bbox-crs-epsg: - description: Indicates the EPSG for the given bbox coordinates. - explode: false - in: query - name: bbox-crs - required: false - schema: - default: 4326 - type: integer - style: form - crs: - description: Indicates the coordinate reference system for the results. - explode: false - in: query - name: crs - required: false - schema: - format: uri - type: string - style: form - f: - description: The optional f parameter indicates the output format which the - server shall provide as part of the response document. The default format - is GeoJSON. - explode: false - in: query - name: f - required: false - schema: - default: json - enum: - - json - - html - - jsonld - type: string - style: form - lang: - description: The optional lang parameter instructs the server return a response - in a certain language, if supported. If the language is not among the available - values, the Accept-Language header language will be used if it is supported. - If the header is missing, the default server language is used. Note that providers - may only support a single language (or often no language at all), that can - be different from the server language. Language strings can be written in - a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de") - or locale-like (e.g. "de-CH" or "fr_BE") fashion. - in: query - name: lang - required: false - schema: - default: en-US - enum: - - en-US - - fr-CA - type: string - offset: - description: The optional offset parameter indicates the index within the result - set from which the server shall begin presenting results in the response document. The - first element has an index of 0 (default). - explode: false - in: query - name: offset - required: false - schema: - default: 0 - minimum: 0 - type: integer - style: form - resourceId: - description: Configuration resource identifier - in: path - name: resourceId - required: true - schema: - default: dhis2-org-units - type: string - skipGeometry: - description: This option can be used to skip response geometries for each feature. - explode: false - in: query - name: skipGeometry - required: false - schema: - default: false - type: boolean - style: form - vendorSpecificParameters: - description: Additional "free-form" parameters that are not explicitly defined - in: query - name: vendorSpecificParameters - schema: - additionalProperties: true - type: object - style: form - responses: - '200': - description: successful operation - '204': - description: no content - Queryables: - content: - application/json: - schema: - $ref: '#/components/schemas/queryables' - description: successful queryables operation - Tiles: - content: - application/json: - schema: - $ref: '#/components/schemas/tiles' - description: Retrieves the tiles description for this collection - default: - content: - application/json: - schema: - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/exception.yaml - description: Unexpected error - schemas: - queryable: - properties: - description: - description: a human-readable narrative describing the queryable - type: string - language: - default: en - description: the language used for the title and description - type: string - queryable: - description: the token that may be used in a CQL predicate - type: string - title: - description: a human readable title for the queryable - type: string - type: - description: the data type of the queryable - type: string - type-ref: - description: a reference to the formal definition of the type - format: url - type: string - required: - - queryable - - type - type: object - queryables: - properties: - queryables: - items: - $ref: '#/components/schemas/queryable' - type: array - required: - - queryables - type: object - tilematrixsetlink: - properties: - tileMatrixSet: - type: string - tileMatrixSetURI: - type: string - required: - - tileMatrixSet - type: object - tiles: - properties: - links: - items: - $ref: https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml#/components/schemas/link - type: array - tileMatrixSetLinks: - items: - $ref: '#/components/schemas/tilematrixsetlink' - type: array - required: - - tileMatrixSetLinks - - links - type: object -info: - contact: - name: DHIS2 EO API - url: https://dhis2.org - x-ogc-serviceContact: - addresses: [] - emails: - - value: climate@dhis2.org - hoursOfService: pointOfContact - name: DHIS2 Climate Team - description: OGC API compliant geospatial data API - license: - name: CC-BY 4.0 license - url: https://creativecommons.org/licenses/by/4.0/ - termsOfService: https://creativecommons.org/licenses/by/4.0/ - title: DHIS2 EO API - version: 0.22.0 - x-keywords: - - geospatial - - data - - api -openapi: 3.0.2 -paths: - /: - get: - description: Landing page - operationId: getLandingPage - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Landing page - tags: - - server - /collections: - get: - description: Collections - operationId: getCollections - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Collections - tags: - - server - /collections/chirps-precipitation: - get: - description: CHIRPS v3 daily precipitation - operationId: describeChirps-precipitationCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get CHIRPS v3 daily precipitation metadata - tags: - - chirps-precipitation - /collections/chirps-precipitation/area: - get: - description: CHIRPS v3 daily precipitation - operationId: queryAreaChirps-precipitation - parameters: - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/areaCoords.yaml - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/parameter-name.yaml - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/z.yaml - - $ref: '#/components/parameters/f' - responses: - '200': - content: - application/prs.coverage+json: - schema: - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/schemas/coverageJSON.yaml - description: Response - summary: query CHIRPS v3 daily precipitation by area - tags: - - chirps-precipitation - /collections/chirps-precipitation/cube: - get: - description: CHIRPS v3 daily precipitation - operationId: queryCubeChirps-precipitation - parameters: - - description: Only features that have a geometry that intersects the bounding - box are selected.The bounding box is provided as four or six numbers, depending - on whether the coordinate reference system includes a vertical axis (height - or depth). - explode: false - in: query - name: bbox - required: true - schema: - items: - type: number - maxItems: 6 - minItems: 4 - type: array - style: form - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/parameter-name.yaml - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/z.yaml - - $ref: '#/components/parameters/f' - responses: - '200': - content: - application/prs.coverage+json: - schema: - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/schemas/coverageJSON.yaml - description: Response - summary: query CHIRPS v3 daily precipitation by cube - tags: - - chirps-precipitation - /collections/chirps-precipitation/position: - get: - description: CHIRPS v3 daily precipitation - operationId: queryPositionChirps-precipitation - parameters: - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/positionCoords.yaml - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/parameter-name.yaml - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/z.yaml - - $ref: '#/components/parameters/f' - responses: - '200': - content: - application/prs.coverage+json: - schema: - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/schemas/coverageJSON.yaml - description: Response - summary: query CHIRPS v3 daily precipitation by position - tags: - - chirps-precipitation - /collections/dhis2-org-units: - get: - description: Get organization units from DHIS2 - operationId: describeDhis2-org-unitsCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get DHIS2 organization units metadata - tags: - - dhis2-org-units - /collections/dhis2-org-units/items: - get: - description: Get organization units from DHIS2 - operationId: getDhis2-org-unitsFeatures - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - - $ref: '#/components/parameters/bbox' - - description: The optional limit parameter limits the number of items that - are presented in the response document (maximum=50, default=20). - explode: false - in: query - name: limit - required: false - schema: - default: 20 - maximum: 50 - minimum: 1 - type: integer - style: form - - $ref: '#/components/parameters/crs' - - $ref: '#/components/parameters/bbox-crs' - - description: The properties that should be included for each feature. The - parameter value is a comma-separated list of property names. - explode: false - in: query - name: properties - required: false - schema: - items: - enum: [] - type: string - type: array - style: form - - $ref: '#/components/parameters/vendorSpecificParameters' - - $ref: '#/components/parameters/skipGeometry' - - $ref: https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi/parameters/sortby.yaml - - $ref: '#/components/parameters/offset' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get DHIS2 organization units items - tags: - - dhis2-org-units - options: - description: Get organization units from DHIS2 - operationId: optionsDhis2-org-unitsFeatures - responses: - '200': - description: options response - summary: Options for DHIS2 organization units items - tags: - - dhis2-org-units - post: - description: Get organization units from DHIS2 - operationId: getCQL2Dhis2-org-unitsFeatures - requestBody: - content: - application/json: - schema: - $ref: https://schemas.opengis.net/cql2/1.0/cql2.json - description: Get items with CQL2 - required: true - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get DHIS2 organization units items with CQL2 - tags: - - dhis2-org-units - /collections/dhis2-org-units/items/{featureId}: - get: - description: Get organization units from DHIS2 - operationId: getDhis2-org-unitsFeature - parameters: - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId - - $ref: '#/components/parameters/crs' - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Feature - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get DHIS2 organization units item by id - tags: - - dhis2-org-units - options: - description: Get organization units from DHIS2 - operationId: optionsDhis2-org-unitsFeature - parameters: - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId - responses: - '200': - description: options response - summary: Options for DHIS2 organization units item by id - tags: - - dhis2-org-units - /collections/era5-land: - get: - description: ERA5-Land hourly data on single levels from 1981 to present - operationId: describeEra5-landCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get ERA5-Land hourly metadata - tags: - - era5-land - /collections/era5-land/area: - get: - description: ERA5-Land hourly data on single levels from 1981 to present - operationId: queryAreaEra5-land - parameters: - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/areaCoords.yaml - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/parameter-name.yaml - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/z.yaml - - $ref: '#/components/parameters/f' - responses: - '200': - content: - application/prs.coverage+json: - schema: - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/schemas/coverageJSON.yaml - description: Response - summary: query ERA5-Land hourly data on single levels from 1981 to present by - area - tags: - - era5-land - /collections/era5-land/cube: - get: - description: ERA5-Land hourly data on single levels from 1981 to present - operationId: queryCubeEra5-land - parameters: - - description: Only features that have a geometry that intersects the bounding - box are selected.The bounding box is provided as four or six numbers, depending - on whether the coordinate reference system includes a vertical axis (height - or depth). - explode: false - in: query - name: bbox - required: true - schema: - items: - type: number - maxItems: 6 - minItems: 4 - type: array - style: form - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/parameter-name.yaml - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/z.yaml - - $ref: '#/components/parameters/f' - responses: - '200': - content: - application/prs.coverage+json: - schema: - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/schemas/coverageJSON.yaml - description: Response - summary: query ERA5-Land hourly data on single levels from 1981 to present by - cube - tags: - - era5-land - /collections/era5-land/position: - get: - description: ERA5-Land hourly data on single levels from 1981 to present - operationId: queryPositionEra5-land - parameters: - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/positionCoords.yaml - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/parameter-name.yaml - - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/parameters/z.yaml - - $ref: '#/components/parameters/f' - responses: - '200': - content: - application/prs.coverage+json: - schema: - $ref: https://schemas.opengis.net/ogcapi/edr/1.0/openapi/schemas/coverageJSON.yaml - description: Response - summary: query ERA5-Land hourly data on single levels from 1981 to present by - position - tags: - - era5-land - /collections/gdps-temperature: - get: - description: Global Deterministic Prediction System sample - operationId: describeGdps-temperatureCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Global Deterministic Prediction System sample metadata - tags: - - gdps-temperature - /collections/gdps-temperature/coverage: - get: - description: Global Deterministic Prediction System sample - operationId: getGdps-temperatureCoverage - parameters: - - $ref: '#/components/parameters/lang' - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/bbox' - - $ref: '#/components/parameters/bbox-crs' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Global Deterministic Prediction System sample coverage - tags: - - gdps-temperature - /collections/lakes: - get: - description: lakes of the world, public domain - operationId: describeLakesCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Large Lakes metadata - tags: - - lakes - /collections/lakes/items: - get: - description: lakes of the world, public domain - operationId: getLakesFeatures - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - - $ref: '#/components/parameters/bbox' - - description: The optional limit parameter limits the number of items that - are presented in the response document (maximum=50, default=20). - explode: false - in: query - name: limit - required: false - schema: - default: 20 - maximum: 50 - minimum: 1 - type: integer - style: form - - $ref: '#/components/parameters/crs' - - $ref: '#/components/parameters/bbox-crs' - - &id001 - description: The properties that should be included for each feature. The - parameter value is a comma-separated list of property names. - explode: false - in: query - name: properties - required: false - schema: - items: - enum: - - id - - scalerank - - name - - name_alt - - admin - - featureclass - type: string - type: array - style: form - - $ref: '#/components/parameters/vendorSpecificParameters' - - $ref: '#/components/parameters/skipGeometry' - - $ref: https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi/parameters/sortby.yaml - - $ref: '#/components/parameters/offset' - - explode: false - in: query - name: id - required: false - schema: - type: integer - style: form - - explode: false - in: query - name: scalerank - required: false - schema: - type: integer - style: form - - explode: false - in: query - name: name - required: false - schema: - type: string - style: form - - explode: false - in: query - name: name_alt - required: false - schema: - type: string - style: form - - explode: false - in: query - name: admin - required: false - schema: - type: string - style: form - - explode: false - in: query - name: featureclass - required: false - schema: - type: string - style: form - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Large Lakes items - tags: - - lakes - options: - description: lakes of the world, public domain - operationId: optionsLakesFeatures - responses: - '200': - description: options response - summary: Options for Large Lakes items - tags: - - lakes - post: - description: lakes of the world, public domain - operationId: getCQL2LakesFeatures - requestBody: - content: - application/json: - schema: - $ref: https://schemas.opengis.net/cql2/1.0/cql2.json - description: Get items with CQL2 - required: true - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Large Lakes items with CQL2 - tags: - - lakes - /collections/lakes/items/{featureId}: - get: - description: lakes of the world, public domain - operationId: getLakesFeature - parameters: - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId - - $ref: '#/components/parameters/crs' - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Feature - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Large Lakes item by id - tags: - - lakes - options: - description: lakes of the world, public domain - operationId: optionsLakesFeature - parameters: - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId - responses: - '200': - description: options response - summary: Options for Large Lakes item by id - tags: - - lakes - /collections/lakes/queryables: - get: - description: lakes of the world, public domain - operationId: getLakesQueryables - parameters: - - *id001 - - $ref: '#/components/parameters/f' - - &id003 - description: The profile to be applied to a given request - explode: false - in: query - name: profile - required: false - schema: - enum: - - actual-domain - - valid-domain - type: string - style: form - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: '#/components/responses/Queryables' - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Large Lakes queryables - tags: - - lakes - /collections/lakes/schema: - get: - description: lakes of the world, public domain - operationId: getLakesSchema - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: '#/components/responses/Queryables' - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Large Lakes schema - tags: - - lakes - /collections/mapserver_world_map: - get: - description: MapServer demo WMS world map - operationId: describeMapserver_world_mapCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get MapServer demo WMS world map metadata - tags: - - mapserver_world_map - /collections/mapserver_world_map/map: - get: - description: MapServer demo WMS world map map - operationId: getMap - parameters: - - $ref: '#/components/parameters/bbox' - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/datetime - - description: Response image width - explode: false - in: query - name: width - required: false - schema: - type: integer - style: form - - description: Response image height - explode: false - in: query - name: height - required: false - schema: - type: integer - style: form - - description: Background transparency of map (default=true). - explode: false - in: query - name: transparent - required: false - schema: - default: true - type: boolean - style: form - - $ref: '#/components/parameters/bbox-crs-epsg' - - description: The optional f parameter indicates the output format which the - server shall provide as part of the response document. The default format - is GeoJSON. - explode: false - in: query - name: f - required: false - schema: - default: png - enum: - - png - type: string - style: form - responses: - '200': - content: - application/json: {} - description: Response - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get map - tags: - - mapserver_world_map - /collections/obs: - get: - description: My cool observations - operationId: describeObsCollection - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Observations metadata - tags: - - obs - /collections/obs/items: - get: - description: My cool observations - operationId: getObsFeatures - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - - $ref: '#/components/parameters/bbox' - - description: The optional limit parameter limits the number of items that - are presented in the response document (maximum=50, default=20). - explode: false - in: query - name: limit - required: false - schema: - default: 20 - maximum: 50 - minimum: 1 - type: integer - style: form - - $ref: '#/components/parameters/crs' - - $ref: '#/components/parameters/bbox-crs' - - &id002 - description: The properties that should be included for each feature. The - parameter value is a comma-separated list of property names. - explode: false - in: query - name: properties - required: false - schema: - items: - enum: - - id - - stn_id - - datetime - - value - type: string - type: array - style: form - - $ref: '#/components/parameters/vendorSpecificParameters' - - $ref: '#/components/parameters/skipGeometry' - - $ref: https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi/parameters/sortby.yaml - - $ref: '#/components/parameters/offset' - - explode: false - in: query - name: id - required: false - schema: - type: string - style: form - - explode: false - in: query - name: stn_id - required: false - schema: - type: integer - style: form - - explode: false - in: query - name: datetime - required: false - schema: - type: string - style: form - - explode: false - in: query - name: value - required: false - schema: - type: number - style: form - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Observations items - tags: - - obs - options: - description: My cool observations - operationId: optionsObsFeatures - responses: - '200': - description: options response - summary: Options for Observations items - tags: - - obs - post: - description: My cool observations - operationId: getCQL2ObsFeatures - requestBody: - content: - application/json: - schema: - $ref: https://schemas.opengis.net/cql2/1.0/cql2.json - description: Get items with CQL2 - required: true - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Observations items with CQL2 - tags: - - obs - /collections/obs/items/{featureId}: - get: - description: My cool observations - operationId: getObsFeature - parameters: - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId - - $ref: '#/components/parameters/crs' - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Feature - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Observations item by id - tags: - - obs - options: - description: My cool observations - operationId: optionsObsFeature - parameters: - - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId - responses: - '200': - description: options response - summary: Options for Observations item by id - tags: - - obs - /collections/obs/queryables: - get: - description: My cool observations - operationId: getObsQueryables - parameters: - - *id002 - - $ref: '#/components/parameters/f' - - *id003 - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: '#/components/responses/Queryables' - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Observations queryables - tags: - - obs - /collections/obs/schema: - get: - description: My cool observations - operationId: getObsSchema - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: '#/components/responses/Queryables' - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '404': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: Get Observations schema - tags: - - obs - /conformance: - get: - description: API conformance definition - operationId: getConformanceDeclaration - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - '500': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError - summary: API conformance definition - tags: - - server - /jobs: - get: - description: Retrieve a list of jobs - operationId: getJobs - responses: - '200': - $ref: '#/components/responses/200' - '404': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml - default: - $ref: '#/components/responses/default' - summary: Retrieve jobs list - tags: - - jobs - /jobs/{jobId}: - delete: - description: Cancel / delete job - operationId: deleteJob - parameters: - - &id004 - description: job identifier - in: path - name: jobId - required: true - schema: - type: string - responses: - '204': - $ref: '#/components/responses/204' - '404': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml - default: - $ref: '#/components/responses/default' - summary: Cancel / delete job - tags: - - jobs - get: - description: Retrieve job details - operationId: getJob - parameters: - - *id004 - - $ref: '#/components/parameters/f' - responses: - '200': - $ref: '#/components/responses/200' - '404': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml - default: - $ref: '#/components/responses/default' - summary: Retrieve job details - tags: - - jobs - /jobs/{jobId}/results: - get: - description: Retrieve job results - operationId: getJobResults - parameters: - - *id004 - - $ref: '#/components/parameters/f' - responses: - '200': - $ref: '#/components/responses/200' - '404': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml - default: - $ref: '#/components/responses/default' - summary: Retrieve job results - tags: - - jobs - /openapi: - get: - description: This document - operationId: getOpenapi - parameters: - - $ref: '#/components/parameters/f' - - $ref: '#/components/parameters/lang' - - description: UI to render the OpenAPI document - explode: false - in: query - name: ui - required: false - schema: - default: swagger - enum: - - swagger - - redoc - type: string - style: form - responses: - '200': - $ref: '#/components/responses/200' - '400': - $ref: https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter - default: - $ref: '#/components/responses/default' - summary: This document - tags: - - server - /processes: - get: - description: Processes - operationId: getProcesses - parameters: - - $ref: '#/components/parameters/f' - responses: - '200': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ProcessList.yaml - default: - $ref: '#/components/responses/default' - summary: Processes - tags: - - server - /processes/hello-world: - get: - description: An example process that takes a name as input, and echoes it back - as output. Intended to demonstrate a simple process with a single literal - input. - operationId: describeHello-worldProcess - parameters: - - $ref: '#/components/parameters/f' - responses: - '200': - $ref: '#/components/responses/200' - default: - $ref: '#/components/responses/default' - summary: Get process metadata - tags: - - hello-world - /processes/hello-world/execution: - post: - description: An example process that takes a name as input, and echoes it back - as output. Intended to demonstrate a simple process with a single literal - input. - operationId: executeHello-worldJob - parameters: - - description: Indicates client preferences, including whether the client is - capable of asynchronous processing. - in: header - name: Prefer - required: false - schema: - enum: - - respond-async - type: string - requestBody: - content: - application/json: - example: - inputs: - message: An optional message. - name: World - schema: - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/execute.yaml - description: Mandatory execute request JSON - required: true - responses: - '200': - content: - application/json: - schema: - type: object - description: Process output schema - '201': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ExecuteAsync.yaml - '404': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml - '500': - $ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ServerError.yaml - default: - $ref: '#/components/responses/default' - summary: Process Hello World execution - tags: - - hello-world -servers: -- description: OGC API compliant geospatial data API - url: http://127.0.0.1:8000/ogcapi -tags: -- description: OGC API compliant geospatial data API - externalDocs: - description: information - url: https://example.org - name: server -- description: Get organization units from DHIS2 - name: dhis2-org-units -- description: CHIRPS v3 daily precipitation - name: chirps-precipitation -- description: ERA5-Land hourly data on single levels from 1981 to present - name: era5-land -- description: My cool observations - name: obs -- description: lakes of the world, public domain - name: lakes -- description: MapServer demo WMS world map - name: mapserver_world_map -- description: Global Deterministic Prediction System sample - name: gdps-temperature -- name: coverages -- name: edr -- name: records -- name: features -- name: maps -- name: processes -- name: jobs -- name: tiles -- name: stac - diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_common.py b/src/eo_api/routers/ogcapi/plugins/dhis2_common.py new file mode 100644 index 0000000..7792b29 --- /dev/null +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_common.py @@ -0,0 +1,117 @@ +"""Shared DHIS2 models, constants, and helpers for org-unit providers.""" + +import os +from datetime import datetime +from typing import Any + +import httpx +from geojson_pydantic import Feature +from geojson_pydantic.geometries import Geometry +from pydantic import BaseModel, Field + +DHIS2_BASE_URL = os.environ["DHIS2_BASE_URL"] +DHIS2_AUTH = (os.environ["DHIS2_USERNAME"], os.environ["DHIS2_PASSWORD"]) +DHIS2_FIELDS = "id,code,name,shortName,level,openingDate,geometry" + + +class DHIS2OrgUnit(BaseModel): + """Organisation unit as returned by the DHIS2 API.""" + + id: str + name: str | None = None + code: str | None = None + shortName: str | None = None + level: int | None = None + openingDate: datetime | None = None + geometry: Geometry | None = None + + +class OrgUnitProperties(BaseModel): + """Feature properties for a DHIS2 org unit.""" + + name: str | None = Field(None, title="Name") + code: str | None = Field(None, title="Code") + shortName: str | None = Field(None, title="Short name") + level: int | None = Field(None, title="Level") + openingDate: str | None = Field(None, title="Opening date") + + +def schema_to_fields(model: type[BaseModel]) -> dict[str, dict[str, str]]: + """Convert a Pydantic model's JSON Schema to pygeoapi field definitions.""" + schema = model.model_json_schema() + fields: dict[str, dict[str, str]] = {} + for name, prop in schema["properties"].items(): + if "anyOf" in prop: + types = [t for t in prop["anyOf"] if t.get("type") != "null"] + field_type = types[0]["type"] if types else "string" + else: + field_type = prop.get("type", "string") + field_def: dict[str, str] = {"type": field_type} + if "title" in prop: + field_def["title"] = prop["title"] + fields[name] = field_def + return fields + + +def flatten_coords(coords: list) -> list[list[float]]: + """Recursively flatten nested coordinate arrays into a list of [x, y] points.""" + if coords and isinstance(coords[0], (int, float)): + return [coords] + result: list[list[float]] = [] + for item in coords: + result.extend(flatten_coords(item)) + return result + + +def compute_bbox(geometry: Geometry) -> tuple[float, float, float, float]: + """Compute bounding box from a GeoJSON geometry.""" + coords = flatten_coords(geometry.model_dump()["coordinates"]) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + return (min(xs), min(ys), max(xs), max(ys)) + + +def fetch_org_units() -> list[DHIS2OrgUnit]: + """Fetch all organisation units from the DHIS2 API.""" + response = httpx.get( + f"{DHIS2_BASE_URL}/organisationUnits", + params={"paging": "false", "fields": DHIS2_FIELDS}, + auth=DHIS2_AUTH, + follow_redirects=True, + ) + response.raise_for_status() + return [DHIS2OrgUnit.model_validate(ou) for ou in response.json()["organisationUnits"]] + + +def org_unit_to_feature(org_unit: DHIS2OrgUnit) -> Feature: + """Convert a DHIS2 org unit to a GeoJSON Feature.""" + props = OrgUnitProperties( + name=org_unit.name, + code=org_unit.code, + shortName=org_unit.shortName, + level=org_unit.level, + openingDate=org_unit.openingDate.isoformat() if org_unit.openingDate else None, + ) + bbox = None + if org_unit.geometry and org_unit.geometry.type in ("Polygon", "MultiPolygon"): + bbox = compute_bbox(org_unit.geometry) + return Feature( + type="Feature", + id=org_unit.id, + geometry=org_unit.geometry, + properties=props.model_dump(), + bbox=bbox, + ) + + +def get_single_org_unit(identifier: str) -> dict[str, Any]: + """Fetch a single org unit by ID and return as a feature dict.""" + response = httpx.get( + f"{DHIS2_BASE_URL}/organisationUnits/{identifier}", + params={"fields": DHIS2_FIELDS}, + auth=DHIS2_AUTH, + follow_redirects=True, + ) + response.raise_for_status() + org_unit = DHIS2OrgUnit.model_validate(response.json()) + return org_unit_to_feature(org_unit).model_dump() diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index 3589a7c..80ae57e 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -1,75 +1,21 @@ """DHIS2 Organization Units feature provider for pygeoapi.""" -import os -from datetime import datetime from typing import Any import httpx -from geojson_pydantic import Feature, FeatureCollection -from geojson_pydantic.geometries import Geometry -from pydantic import BaseModel, Field +from geojson_pydantic import FeatureCollection from pygeoapi.provider.base import BaseProvider, SchemaType -DHIS2_BASE_URL = os.environ["DHIS2_BASE_URL"] -DHIS2_AUTH = (os.environ["DHIS2_USERNAME"], os.environ["DHIS2_PASSWORD"]) -DHIS2_FIELDS = "id,code,name,shortName,level,openingDate,geometry" - - -class DHIS2OrgUnit(BaseModel): - """Organisation unit as returned by the DHIS2 API.""" - - id: str - name: str | None = None - code: str | None = None - shortName: str | None = None - level: int | None = None - openingDate: datetime | None = None - geometry: Geometry | None = None - - -class OrgUnitProperties(BaseModel): - """Feature properties for a DHIS2 org unit.""" - - name: str | None = Field(None, title="Name") - code: str | None = Field(None, title="Code") - shortName: str | None = Field(None, title="Short name") - level: int | None = Field(None, title="Level") - openingDate: str | None = Field(None, title="Opening date") - - -def _schema_to_fields(model: type[BaseModel]) -> dict[str, dict[str, str]]: - """Convert a Pydantic model's JSON Schema to pygeoapi field definitions.""" - schema = model.model_json_schema() - fields = {} - for name, prop in schema["properties"].items(): - if "anyOf" in prop: - types = [t for t in prop["anyOf"] if t.get("type") != "null"] - field_type = types[0]["type"] if types else "string" - else: - field_type = prop.get("type", "string") - field_def: dict[str, str] = {"type": field_type} - if "title" in prop: - field_def["title"] = prop["title"] - fields[name] = field_def - return fields - - -def _flatten_coords(coords: list) -> list[list[float]]: - """Recursively flatten nested coordinate arrays into a list of [x, y] points.""" - if coords and isinstance(coords[0], (int, float)): - return [coords] - result = [] - for item in coords: - result.extend(_flatten_coords(item)) - return result - - -def _compute_bbox(geometry: Geometry) -> tuple[float, float, float, float]: - """Compute bounding box from a GeoJSON geometry.""" - coords = _flatten_coords(geometry.model_dump()["coordinates"]) - xs = [c[0] for c in coords] - ys = [c[1] for c in coords] - return (min(xs), min(ys), max(xs), max(ys)) +from eo_api.routers.ogcapi.plugins.dhis2_common import ( + DHIS2_AUTH, + DHIS2_BASE_URL, + OrgUnitProperties, + fetch_org_units, + flatten_coords, + get_single_org_unit, + org_unit_to_feature, + schema_to_fields, +) def _fetch_bbox() -> list[float] | None: @@ -89,7 +35,7 @@ def _fetch_bbox() -> list[float] | None: for ou in response.json()["organisationUnits"]: geom = ou.get("geometry") if geom and geom.get("coordinates"): - all_coords.extend(_flatten_coords(geom["coordinates"])) + all_coords.extend(flatten_coords(geom["coordinates"])) if not all_coords: return None xs = [c[0] for c in all_coords] @@ -97,39 +43,6 @@ def _fetch_bbox() -> list[float] | None: return [min(xs), min(ys), max(xs), max(ys)] -def _fetch_org_units() -> list[DHIS2OrgUnit]: - """Fetch all organisation units from the DHIS2 API.""" - response = httpx.get( - f"{DHIS2_BASE_URL}/organisationUnits", - params={"paging": "false", "fields": DHIS2_FIELDS}, - auth=DHIS2_AUTH, - follow_redirects=True, - ) - response.raise_for_status() - return [DHIS2OrgUnit.model_validate(ou) for ou in response.json()["organisationUnits"]] - - -def _org_unit_to_feature(org_unit: DHIS2OrgUnit) -> Feature: - """Convert a DHIS2 org unit to a GeoJSON Feature.""" - props = OrgUnitProperties( - name=org_unit.name, - code=org_unit.code, - shortName=org_unit.shortName, - level=org_unit.level, - openingDate=org_unit.openingDate.isoformat() if org_unit.openingDate else None, - ) - bbox = None - if org_unit.geometry and org_unit.geometry.type in ("Polygon", "MultiPolygon"): - bbox = _compute_bbox(org_unit.geometry) - return Feature( - type="Feature", - id=org_unit.id, - geometry=org_unit.geometry, - properties=props.model_dump(), - bbox=bbox, - ) - - class DHIS2OrgUnitsProvider(BaseProvider): """DHIS2 Organization Units Provider.""" @@ -141,20 +54,12 @@ def __init__(self, provider_def: dict[str, Any]) -> None: def get_fields(self) -> dict[str, dict[str, str]]: """Return fields and their datatypes.""" if not self._fields: - self._fields = _schema_to_fields(OrgUnitProperties) + self._fields = schema_to_fields(OrgUnitProperties) return self._fields def get(self, identifier: str, **kwargs: Any) -> dict[str, Any]: """Return a single feature by identifier.""" - response = httpx.get( - f"{DHIS2_BASE_URL}/organisationUnits/{identifier}", - params={"fields": DHIS2_FIELDS}, - auth=DHIS2_AUTH, - follow_redirects=True, - ) - response.raise_for_status() - org_unit = DHIS2OrgUnit.model_validate(response.json()) - return _org_unit_to_feature(org_unit).model_dump() + return get_single_org_unit(identifier) def query( self, @@ -170,13 +75,13 @@ def query( **kwargs: Any, ) -> dict[str, Any]: """Return feature collection matching the query parameters.""" - org_units = _fetch_org_units() + org_units = fetch_org_units() number_matched = len(org_units) page = org_units[offset : offset + limit] fc = FeatureCollection( type="FeatureCollection", - features=[_org_unit_to_feature(ou) for ou in page], + features=[org_unit_to_feature(ou) for ou in page], ) result = fc.model_dump() result["numberMatched"] = number_matched diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py new file mode 100644 index 0000000..b33226d --- /dev/null +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py @@ -0,0 +1,76 @@ +"""DHIS2 Organization Units feature provider with CQL filter support.""" + +from typing import Any + +from geojson_pydantic import FeatureCollection +from pygeoapi.provider.base import BaseProvider, SchemaType +from pygeofilter.backends.native.evaluate import NativeEvaluator + +from eo_api.routers.ogcapi.plugins.dhis2_common import ( + OrgUnitProperties, + fetch_org_units, + get_single_org_unit, + org_unit_to_feature, + schema_to_fields, +) + + +class DHIS2OrgUnitsCqlProvider(BaseProvider): + """DHIS2 Organization Units Provider with CQL filter support.""" + + def __init__(self, provider_def: dict[str, Any]) -> None: + """Inherit from parent class.""" + super().__init__(provider_def) + self.get_fields() + + def get_fields(self) -> dict[str, dict[str, str]]: + """Return fields and their datatypes.""" + if not self._fields: + self._fields = schema_to_fields(OrgUnitProperties) + return self._fields + + def get(self, identifier: str, **kwargs: Any) -> dict[str, Any]: + """Return a single feature by identifier.""" + return get_single_org_unit(identifier) + + def query( + self, + offset: int = 0, + limit: int = 10, + resulttype: str = "results", + bbox: list[float] | None = None, + datetime_: str | None = None, + properties: list[str] | None = None, + sortby: list[str] | None = None, + select_properties: list[str] | None = None, + skip_geometry: bool = False, + filterq: list | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Return feature collection matching the query parameters.""" + org_units = fetch_org_units() + features = [org_unit_to_feature(ou).model_dump() for ou in org_units] + + if filterq: + evaluator = NativeEvaluator(use_getattr=False) + match = evaluator.evaluate(filterq) + features = [f for f in features if match(f["properties"])] + + number_matched = len(features) + page = features[offset : offset + limit] + + fc = FeatureCollection( + type="FeatureCollection", + features=page, + ) + result = fc.model_dump() + result["numberMatched"] = number_matched + result["numberReturned"] = len(page) + return result + + def get_schema(self, schema_type: SchemaType = SchemaType.item) -> tuple[str, dict[str, Any]]: + """Return a JSON schema for the provider.""" + return ( + "application/geo+json", + {"$ref": "https://geojson.org/schema/Feature.json"}, + ) From 5e33407e1dba3f8c1092239620d7ce14bcd170b0 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 00:18:27 +0100 Subject: [PATCH 30/36] docs: add CQL filtering section to ogcapi.md --- docs/ogcapi.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/ogcapi.md b/docs/ogcapi.md index 318f694..305789d 100644 --- a/docs/ogcapi.md +++ b/docs/ogcapi.md @@ -122,6 +122,79 @@ The `type` field on a provider determines which OGC API standard the collection A single collection can have multiple providers (e.g. both `feature` and `tile` on the same resource). +## CQL filtering + +pygeoapi supports [CQL2](https://docs.ogc.org/is/21-065r2/21-065r2.html) text filters on collections backed by a CQL-capable provider. Filters are passed as query parameters: + +``` +?filter=&filter-lang=cql-text +``` + +The `dhis2-org-units-cql` collection exposes this capability. Its filterable properties are `name`, `code`, `shortName`, `level`, and `openingDate`. + +### Supported operators + +| Category | Operators | Example | +|---|---|---| +| Comparison | `=`, `<>`, `<`, `<=`, `>`, `>=` | `level=2` | +| Pattern matching | `LIKE`, `ILIKE` (`%` = any chars, `_` = single char) | `name LIKE '%Hospital%'` | +| Range | `BETWEEN ... AND ...` | `level BETWEEN 2 AND 3` | +| Set membership | `IN (...)` | `level IN (1,2)` | +| Null checks | `IS NULL`, `IS NOT NULL` | `code IS NOT NULL` | +| Logical | `AND`, `OR`, `NOT` | `level=3 AND name LIKE '%CH%'` | + +String values must be enclosed in **single quotes**. + +### Example queries + +Exact match on level: + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=level=2&filter-lang=cql-text +``` + +String match on name: + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=name='0002 CH Mittaphap'&filter-lang=cql-text +``` + +LIKE (case-sensitive pattern): + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=name LIKE '%Hospital%'&filter-lang=cql-text +``` + +ILIKE (case-insensitive pattern): + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=name ILIKE '%hospital%'&filter-lang=cql-text +``` + +Combined filter with AND: + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=level=3 AND name LIKE '%CH%'&filter-lang=cql-text +``` + +BETWEEN range: + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=level BETWEEN 2 AND 3&filter-lang=cql-text +``` + +IN set membership: + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=level IN (1,2)&filter-lang=cql-text +``` + +NULL check combined with comparison: + +``` +/ogcapi/collections/dhis2-org-units-cql/items?filter=code IS NULL AND level=5&filter-lang=cql-text +``` + ## Plugin system pygeoapi uses a plugin architecture so that new data backends, output formats, and processing tasks can be added without modifying the core. From 7ec27ea8228b0526d87ea7da4d2a44aed1907bc0 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 00:20:50 +0100 Subject: [PATCH 31/36] docs: remove redundant filter-lang=cql-text from CQL examples pygeoapi defaults to CQL text, so the filter-lang parameter is unnecessary. --- docs/ogcapi.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/ogcapi.md b/docs/ogcapi.md index 305789d..f40a8f0 100644 --- a/docs/ogcapi.md +++ b/docs/ogcapi.md @@ -127,7 +127,7 @@ A single collection can have multiple providers (e.g. both `feature` and `tile` pygeoapi supports [CQL2](https://docs.ogc.org/is/21-065r2/21-065r2.html) text filters on collections backed by a CQL-capable provider. Filters are passed as query parameters: ``` -?filter=&filter-lang=cql-text +?filter= ``` The `dhis2-org-units-cql` collection exposes this capability. Its filterable properties are `name`, `code`, `shortName`, `level`, and `openingDate`. @@ -150,49 +150,49 @@ String values must be enclosed in **single quotes**. Exact match on level: ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=level=2&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=level=2 ``` String match on name: ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=name='0002 CH Mittaphap'&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=name='0002 CH Mittaphap' ``` LIKE (case-sensitive pattern): ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=name LIKE '%Hospital%'&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=name LIKE '%Hospital%' ``` ILIKE (case-insensitive pattern): ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=name ILIKE '%hospital%'&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=name ILIKE '%hospital%' ``` Combined filter with AND: ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=level=3 AND name LIKE '%CH%'&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=level=3 AND name LIKE '%CH%' ``` BETWEEN range: ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=level BETWEEN 2 AND 3&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=level BETWEEN 2 AND 3 ``` IN set membership: ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=level IN (1,2)&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=level IN (1,2) ``` NULL check combined with comparison: ``` -/ogcapi/collections/dhis2-org-units-cql/items?filter=code IS NULL AND level=5&filter-lang=cql-text +/ogcapi/collections/dhis2-org-units-cql/items?filter=code IS NULL AND level=5 ``` ## Plugin system From 7cdcac9f81a9fd32294e214f04f58ace62c0aaf6 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 00:22:40 +0100 Subject: [PATCH 32/36] fix: add openapi generation step to CI workflow The test suite imports pygeoapi which requires pygeoapi-openapi.yml to exist at import time. CI was missing the generation step. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da948e3..9499a74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,8 @@ jobs: - name: Run linting run: make lint + - name: Generate OpenAPI spec + run: make openapi + - name: Run tests run: make test From eb51542e0de3d7ed0537b8b1e83a6fe29e068710 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 01:58:36 +0100 Subject: [PATCH 33/36] ci: add Docker image build workflow and GHCR compose file Add GitHub Actions workflow to build and push Docker images to ghcr.io/dhis2/eo-api on pushes to main. Add compose.ghcr.yml for running the pre-built image from the registry. --- .github/workflows/docker.yml | 72 ++++++++++++++++++++++++++++++++++++ compose.ghcr.yml | 8 ++++ 2 files changed, 80 insertions(+) create mode 100644 .github/workflows/docker.yml create mode 100644 compose.ghcr.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..3f334fb --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,72 @@ +name: Build and push Docker image + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: dhis2/eo-api + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Create .env from example + run: cp .env.example .env + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync + + - name: Generate OpenAPI spec + run: make openapi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/compose.ghcr.yml b/compose.ghcr.yml new file mode 100644 index 0000000..f6d0075 --- /dev/null +++ b/compose.ghcr.yml @@ -0,0 +1,8 @@ +services: + api: + image: ghcr.io/dhis2/eo-api:latest + env_file: .env + ports: + - "${PORT:-8000}:${PORT:-8000}" + init: true + restart: unless-stopped From 8b494e8a966b8197294849f936b3fa3a67d578ed Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 10:08:36 +0100 Subject: [PATCH 34/36] fix: apply computed bbox to CQL collection at startup --- src/eo_api/routers/ogcapi/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/eo_api/routers/ogcapi/__init__.py b/src/eo_api/routers/ogcapi/__init__.py index d74214b..d40be20 100644 --- a/src/eo_api/routers/ogcapi/__init__.py +++ b/src/eo_api/routers/ogcapi/__init__.py @@ -36,6 +36,7 @@ bbox = _fetch_bbox() if bbox is not None: CONFIG["resources"]["dhis2-org-units"]["extents"]["spatial"]["bbox"] = [bbox] + CONFIG["resources"]["dhis2-org-units-cql"]["extents"]["spatial"]["bbox"] = [bbox] logger.info("DHIS2 org-units bbox set to %s", bbox) else: logger.info("No level-1 org unit geometry found, skipping bbox") From a0f7d74adafd5597cbb0b8f17d31263f39a76fce Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 10:13:27 +0100 Subject: [PATCH 35/36] refactor: move fetch_bbox to dhis2_common module The function was private in dhis2_org_units.py but only consumed externally. Move it to dhis2_common.py as a public helper alongside fetch_org_units, and remove the now-unused imports from dhis2_org_units. --- src/eo_api/routers/ogcapi/__init__.py | 4 +-- .../routers/ogcapi/plugins/dhis2_common.py | 25 ++++++++++++++++ .../routers/ogcapi/plugins/dhis2_org_units.py | 29 ------------------- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/eo_api/routers/ogcapi/__init__.py b/src/eo_api/routers/ogcapi/__init__.py index d40be20..460e4f5 100644 --- a/src/eo_api/routers/ogcapi/__init__.py +++ b/src/eo_api/routers/ogcapi/__init__.py @@ -28,12 +28,12 @@ from pygeoapi.starlette_app import APP as pygeoapi_app from pygeoapi.starlette_app import CONFIG -from eo_api.routers.ogcapi.plugins.dhis2_org_units import _fetch_bbox +from eo_api.routers.ogcapi.plugins.dhis2_common import fetch_bbox logger = logging.getLogger(__name__) try: - bbox = _fetch_bbox() + bbox = fetch_bbox() if bbox is not None: CONFIG["resources"]["dhis2-org-units"]["extents"]["spatial"]["bbox"] = [bbox] CONFIG["resources"]["dhis2-org-units-cql"]["extents"]["spatial"]["bbox"] = [bbox] diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_common.py b/src/eo_api/routers/ogcapi/plugins/dhis2_common.py index 7792b29..2f101fb 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_common.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_common.py @@ -71,6 +71,31 @@ def compute_bbox(geometry: Geometry) -> tuple[float, float, float, float]: return (min(xs), min(ys), max(xs), max(ys)) +def fetch_bbox() -> list[float] | None: + """Compute bounding box from level-1 org unit geometries.""" + response = httpx.get( + f"{DHIS2_BASE_URL}/organisationUnits", + params={ + "paging": "false", + "fields": "geometry", + "filter": "level:eq:1", + }, + auth=DHIS2_AUTH, + follow_redirects=True, + ) + response.raise_for_status() + all_coords: list[list[float]] = [] + for ou in response.json()["organisationUnits"]: + geom = ou.get("geometry") + if geom and geom.get("coordinates"): + all_coords.extend(flatten_coords(geom["coordinates"])) + if not all_coords: + return None + xs = [c[0] for c in all_coords] + ys = [c[1] for c in all_coords] + return [min(xs), min(ys), max(xs), max(ys)] + + def fetch_org_units() -> list[DHIS2OrgUnit]: """Fetch all organisation units from the DHIS2 API.""" response = httpx.get( diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py index 80ae57e..3c78e05 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units.py @@ -2,47 +2,18 @@ from typing import Any -import httpx from geojson_pydantic import FeatureCollection from pygeoapi.provider.base import BaseProvider, SchemaType from eo_api.routers.ogcapi.plugins.dhis2_common import ( - DHIS2_AUTH, - DHIS2_BASE_URL, OrgUnitProperties, fetch_org_units, - flatten_coords, get_single_org_unit, org_unit_to_feature, schema_to_fields, ) -def _fetch_bbox() -> list[float] | None: - """Compute bounding box from level-1 org unit geometries.""" - response = httpx.get( - f"{DHIS2_BASE_URL}/organisationUnits", - params={ - "paging": "false", - "fields": "geometry", - "filter": "level:eq:1", - }, - auth=DHIS2_AUTH, - follow_redirects=True, - ) - response.raise_for_status() - all_coords: list[list[float]] = [] - for ou in response.json()["organisationUnits"]: - geom = ou.get("geometry") - if geom and geom.get("coordinates"): - all_coords.extend(flatten_coords(geom["coordinates"])) - if not all_coords: - return None - xs = [c[0] for c in all_coords] - ys = [c[1] for c in all_coords] - return [min(xs), min(ys), max(xs), max(ys)] - - class DHIS2OrgUnitsProvider(BaseProvider): """DHIS2 Organization Units Provider.""" From 990ff231ee49167073bb702abcf15f03df9fa527 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Wed, 25 Feb 2026 10:14:16 +0100 Subject: [PATCH 36/36] fix: build result dict directly in CQL provider query Features are already dicts from model_dump(), so passing them through FeatureCollection caused a Pyright type error. Build the result dict directly and drop the unused FeatureCollection import. --- .../routers/ogcapi/plugins/dhis2_org_units_cql.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py index b33226d..68cbf78 100644 --- a/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py +++ b/src/eo_api/routers/ogcapi/plugins/dhis2_org_units_cql.py @@ -2,7 +2,6 @@ from typing import Any -from geojson_pydantic import FeatureCollection from pygeoapi.provider.base import BaseProvider, SchemaType from pygeofilter.backends.native.evaluate import NativeEvaluator @@ -59,14 +58,12 @@ def query( number_matched = len(features) page = features[offset : offset + limit] - fc = FeatureCollection( - type="FeatureCollection", - features=page, - ) - result = fc.model_dump() - result["numberMatched"] = number_matched - result["numberReturned"] = len(page) - return result + return { + "type": "FeatureCollection", + "features": page, + "numberMatched": number_matched, + "numberReturned": len(page), + } def get_schema(self, schema_type: SchemaType = SchemaType.item) -> tuple[str, dict[str, Any]]: """Return a JSON schema for the provider."""