diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3ce4edfc..4c48dc5e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,11 @@ Change log .. Headline template: X.Y.Z (YYYY-MM-DD) +3.1.0 (2022-06-01) +================== + +- Add new FastAPI middleware ``CaseInsensitiveQueryMiddleware`` to aid in implementing the IVOA protocol requirement that the keys of query parameters be case-isensitive. + 3.0.3 (2022-05-16) ================== diff --git a/docs/api.rst b/docs/api.rst index 738987a6..5e100473 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -31,6 +31,9 @@ API reference .. automodapi:: safir.models :include-all-objects: +.. automodapi:: safir.middleware.ivoa + :include-all-objects: + .. automodapi:: safir.middleware.x_forwarded :include-all-objects: diff --git a/docs/index.rst b/docs/index.rst index 6566ba9d..1813b7ad 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,6 +38,7 @@ Guides kubernetes logging x-forwarded + ivoa API === diff --git a/docs/ivoa.rst b/docs/ivoa.rst new file mode 100644 index 00000000..a7bbd6c4 --- /dev/null +++ b/docs/ivoa.rst @@ -0,0 +1,36 @@ +##################### +IVOA protocol support +##################### + +The IVOA web protocols aren't entirely RESTful and have some unusual requirements that are not provided by modern web frameworks. +Safir provides some FastAPI support facilities to make implementing IVOA services easier. + +Query parameter case insensitivity +================================== + +Many IVOA protocols require the key of a query parameter to be case-insensitive. +For example, the requests ``GET /api/foo?param=bar`` and ``GET /api/foo?PARAM=bar`` are supposed to produce identical results. +Safir provides `safir.middleware.ivoa.CaseInsensitiveQueryMiddleware` to implement this protocol requirement. + +Add this middleware to the FastAPI application: + +.. code-block:: python + + from safir.middleware.ivoa import CaseInsensitiveQueryMiddleware + + app = FastAPI() + app.add_middleware(CaseInsensitiveQueryMiddleware) + +In the route handlers, declare all query parameters in all lowercase. +For instance, for the above example queries: + +.. code-block:: python + + @app.get("/api/foo") + async def get_foo(param: str) -> Response: + result = do_something_with_param(param) + +The keys of all incoming query parameters will be converted to lowercase by the middleware before the route handler is called. +The value will be unchanged; so, for example, with a request of ``GET /api/foo?PARAM=BAR``, the value of ``param`` will still be ``BAR``, not ``bar``. + +The OpenAPI interface specification will only document the lowercase form of the parameters, but all other case combinations will be supported because the middleware will rewrite the incoming request. diff --git a/src/safir/middleware/ivoa.py b/src/safir/middleware/ivoa.py new file mode 100644 index 00000000..6dd302c2 --- /dev/null +++ b/src/safir/middleware/ivoa.py @@ -0,0 +1,38 @@ +"""Middleware for IVOA services.""" + +from typing import Awaitable, Callable +from urllib.parse import urlencode + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +__all__ = ["CaseInsensitiveQueryMiddleware"] + + +class CaseInsensitiveQueryMiddleware(BaseHTTPMiddleware): + """Make query parameter keys all lowercase. + + Unfortunately, several IVOA standards require that query parameters be + case-insensitive, which is not supported by modern HTTP web frameworks. + This middleware attempts to work around this by lowercasing the query + parameter keys before the request is processed, allowing normal FastAPI + query parsing to then work without regard for case. This, in turn, + permits FastAPI to perform input validation on GET parameters, which would + otherwise only happen if the case used in the request happened to match + the case used in the function signature. + + This unfortunately doesn't handle POST, so routes that accept POST will + need to parse the POST data case-insensitively in the handler or a + dependency. + + Based on `fastapi#826 `__. + """ + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + params = [(k.lower(), v) for k, v in request.query_params.items()] + request.scope["query_string"] = urlencode(params).encode() + return await call_next(request) diff --git a/tests/middleware/ivoa_test.py b/tests/middleware/ivoa_test.py new file mode 100644 index 00000000..d930b280 --- /dev/null +++ b/tests/middleware/ivoa_test.py @@ -0,0 +1,43 @@ +"""Test IVOA middleware.""" + +from __future__ import annotations + +from typing import Dict + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from safir.middleware.ivoa import CaseInsensitiveQueryMiddleware + + +def build_app() -> FastAPI: + """Construct a test FastAPI app with the middleware registered.""" + app = FastAPI() + app.add_middleware(CaseInsensitiveQueryMiddleware) + return app + + +@pytest.mark.asyncio +async def test_case_insensitive() -> None: + app = build_app() + + @app.get("/") + async def handler(param: str) -> Dict[str, str]: + return {"param": param} + + async with AsyncClient(app=app, base_url="https://example.com") as client: + r = await client.get("/", params={"param": "foo"}) + assert r.status_code == 200 + assert r.json() == {"param": "foo"} + + r = await client.get("/", params={"PARAM": "foo"}) + assert r.status_code == 200 + assert r.json() == {"param": "foo"} + + r = await client.get("/", params={"pARam": "foo"}) + assert r.status_code == 200 + assert r.json() == {"param": "foo"} + + r = await client.get("/", params={"paramX": "foo"}) + assert r.status_code == 422 diff --git a/tox.ini b/tox.ini index 81b44047..37bebed6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py,coverage-report,typing,lint +envlist = py,coverage-report,typing,lint,docs isolated_build = True [docker:postgres]