Skip to content

Commit 435c657

Browse files
feat(app): FastAPI routes for Dispatcher + WireCodec (step 5)
Adds mobilityapi/app.py + two routers that expose the catalog-driven Dispatcher (PR #6) and WireCodec (PR #7) as HTTP endpoints: - GET /catalog -> list dispatcher-exposable functions - GET /catalog/{name} -> full signature for one function - POST /functions/{name} -> invoke with JSON body, decode/encode opaque MEOS types via the WireCodec The app is built by create_app(dispatcher, codec) — both dependencies are explicit; no global singletons. Production wires both to PyMEOS; tests pass stubs. The POST /functions/{name} flow: 1. Decode opaque-type params via codec.decode(encoding, wire_value). 2. Dispatch via Dispatcher.dispatch(name, params). 3. Encode the result via codec.encode(encoding, value) if the catalog marks the return as serialised. Error mapping: - Unknown function -> 404 - Missing / extra parameters -> 400 - Param encoding has no codec decoder -> 400 - Result encoding has no codec encoder -> 500 tests/test_app.py: 11 HTTP-level tests against a tiny in-test catalog covering scalar, serialised-in / scalar-out, and serialised-in / serialised-out shapes, plus the four error paths. All 38 framework tests pass.
1 parent 43841a0 commit 435c657

7 files changed

Lines changed: 474 additions & 5 deletions

File tree

.github/workflows/python.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,17 @@ jobs:
4141
python -c "from resource.temporal_geom_query import distance, velocity, acceleration"
4242
4343
pytest-dispatcher:
44-
name: Dispatcher / resolvers / wire unit tests
44+
name: Dispatcher / resolvers / wire / app unit tests
4545
runs-on: ubuntu-latest
4646
steps:
4747
- uses: actions/checkout@v4
4848
- uses: actions/setup-python@v5
4949
with:
5050
python-version: "3.11"
51-
- name: Install pytest
52-
run: pip install --upgrade pip pytest
53-
- name: Run dispatcher tests
54-
run: python -m pytest tests/test_dispatcher.py tests/test_resolvers.py tests/test_wire.py -v
51+
- name: Install dependencies
52+
# `fastapi` + `httpx` are required by test_app.py; the other test
53+
# modules run with pytest alone since the package init lazy-loads
54+
# FastAPI via PEP 562.
55+
run: pip install --upgrade pip pytest fastapi 'httpx>=0.24'
56+
- name: Run framework + app tests
57+
run: python -m pytest tests/test_dispatcher.py tests/test_resolvers.py tests/test_wire.py tests/test_app.py -v

mobilityapi/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,21 @@
2323
)
2424

2525
__all__ = [
26+
"create_app",
2627
"Dispatcher", "FunctionSignature",
2728
"stub_resolver", "pymeos_resolver", "default_resolver",
2829
"WireCodec", "stub_codec", "pymeos_codec",
2930
"ENCODING_MFJSON", "ENCODING_TEXT", "ENCODING_WKB", "ENCODING_HEXWKB",
3031
]
32+
33+
34+
# Lazy-load `create_app` (PEP 562) so importing `mobilityapi` does NOT pull
35+
# in FastAPI/Starlette/Pydantic. Callers that need the HTTP routes import
36+
# `from mobilityapi import create_app` and pay the FastAPI dep then; tests
37+
# that only exercise Dispatcher/Resolvers/WireCodec do not need it on the
38+
# import path.
39+
def __getattr__(name): # noqa: D401 - module-level descriptor
40+
if name == "create_app":
41+
from .app import create_app as _create_app
42+
return _create_app
43+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

mobilityapi/app.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""FastAPI application factory for the MobilityAPI Dispatcher framework.
2+
3+
Exposes the catalog-driven Dispatcher + WireCodec foundation (steps 3 / 4
4+
of the ingestion plan) as HTTP routes. Two router groups:
5+
6+
- ``/catalog/*`` — read-only introspection (list functions, fetch one).
7+
- ``/functions/*`` — invoke a MEOS function from a JSON request body.
8+
9+
The app is built by :func:`create_app`, which takes injected
10+
``Dispatcher`` and ``WireCodec`` instances. Production wires both to
11+
PyMEOS; tests pass stubs. No global singletons; every dependency is
12+
explicit, which keeps the request-time hot path resolver-agnostic and
13+
the test surface fast.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from fastapi import FastAPI
19+
20+
from .dispatcher import Dispatcher
21+
from .routers import catalog, functions
22+
from .wire import WireCodec
23+
24+
25+
def create_app(
26+
dispatcher: Dispatcher,
27+
codec: WireCodec,
28+
*,
29+
title: str = "MobilityAPI",
30+
version: str = "0.1.0",
31+
) -> FastAPI:
32+
"""Build the FastAPI app from the injected dispatcher + codec.
33+
34+
:param dispatcher: ``Dispatcher`` instance bound to a resolver
35+
(``stub_resolver`` for tests, ``pymeos_resolver`` for prod).
36+
:param codec: ``WireCodec`` mapping the catalog's per-parameter
37+
encoding labels (``mfjson`` / ``text`` / ``wkb`` / ``hexwkb``)
38+
to Python factory + serialiser callables.
39+
40+
The dispatcher and codec are exposed to routers via
41+
``app.state.dispatcher`` and ``app.state.codec`` so router
42+
dependencies can read them without a global.
43+
"""
44+
app = FastAPI(
45+
title=title,
46+
version=version,
47+
description=(
48+
"Catalog-driven dispatcher for MEOS functions, exposed over "
49+
"HTTP. Every route delegates to the MobilityAPI Dispatcher; "
50+
"no MEOS C code is invoked outside the dispatcher resolver."
51+
),
52+
)
53+
app.state.dispatcher = dispatcher
54+
app.state.codec = codec
55+
56+
app.include_router(catalog.router, prefix="/catalog", tags=["catalog"])
57+
app.include_router(functions.router, prefix="/functions", tags=["functions"])
58+
59+
return app

mobilityapi/routers/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""FastAPI routers for the MobilityAPI Dispatcher framework.
2+
3+
Each router is a thin shell over the Dispatcher + WireCodec; the heavy
4+
lifting lives in :mod:`mobilityapi.dispatcher` and :mod:`mobilityapi.wire`.
5+
"""

mobilityapi/routers/catalog.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Catalog introspection endpoints.
2+
3+
The MobilityAPI ingestion pipeline (steps 1–3) lands the MEOS catalog as
4+
``vendor/meos-api/meos-idl.json``. Step 4's :class:`Dispatcher` loads
5+
that catalog and filters it to the *exposable* subset (functions whose
6+
catalog entry has ``network.exposable=true`` or is missing).
7+
8+
This router exposes that subset over HTTP so callers can discover what
9+
the deployed instance can dispatch. Two routes:
10+
11+
- ``GET /catalog`` — list function names + categories.
12+
- ``GET /catalog/{name}`` — full signature for one function.
13+
14+
Both routes are read-only and never call into MEOS.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from fastapi import APIRouter, HTTPException, Request
20+
21+
router = APIRouter()
22+
23+
24+
@router.get("", summary="List dispatcher-exposable MEOS function names")
25+
def list_functions(request: Request) -> dict:
26+
dispatcher = request.app.state.dispatcher
27+
items = [
28+
{
29+
"name": sig.name,
30+
"category": sig.category,
31+
"return_type": sig.return_type,
32+
}
33+
for sig in dispatcher.signatures()
34+
]
35+
items.sort(key=lambda x: x["name"])
36+
return {"count": len(items), "functions": items}
37+
38+
39+
@router.get("/{name}", summary="Full signature for one MEOS function")
40+
def get_function(name: str, request: Request) -> dict:
41+
dispatcher = request.app.state.dispatcher
42+
if not dispatcher.has(name):
43+
raise HTTPException(
44+
status_code=404,
45+
detail=(
46+
f"Function `{name}` is not in the dispatcher catalog. "
47+
f"GET /catalog lists available functions."
48+
),
49+
)
50+
sig = dispatcher.signature(name)
51+
return {
52+
"name": sig.name,
53+
"category": sig.category,
54+
"params": sig.params,
55+
"return_type": sig.return_type,
56+
"decode_per_param": sig.decode_per_param,
57+
"encode_return": sig.encode_return,
58+
"description": sig.description,
59+
}

mobilityapi/routers/functions.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Function invocation endpoint.
2+
3+
``POST /functions/{name}`` is the single entry point that converts an
4+
HTTP request body into a Dispatcher call. The body is a JSON object whose
5+
keys match the catalog's parameter names; opaque MEOS types (any
6+
parameter the catalog labels with ``decode_per_param``) are decoded by
7+
the :class:`WireCodec` before the dispatcher invokes the MEOS function,
8+
and the return value is encoded back to a wire-friendly representation
9+
on the way out.
10+
11+
The contract:
12+
13+
- Request body: ``{ "params": { "<arg>": <wire_value>, ... } }``
14+
- Response body for serialised returns:
15+
``{ "result": <wire_value>, "encoding": "mfjson"|"wkb"|... }``
16+
- Response body for scalar returns:
17+
``{ "result": <value>, "encoding": null }``
18+
19+
This is the minimal vocabulary that lets a thin HTTP client (Polars
20+
notebook, Spark UDF, JavaScript map UI) treat every MEOS function the
21+
same way — call by name with a JSON body, read a typed result.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from typing import Any
27+
28+
from fastapi import APIRouter, HTTPException, Request
29+
from pydantic import BaseModel, Field
30+
31+
router = APIRouter()
32+
33+
34+
class InvokeRequest(BaseModel):
35+
"""Body of a POST /functions/{name} request."""
36+
37+
params: dict[str, Any] = Field(
38+
default_factory=dict,
39+
description=(
40+
"Map of parameter name to wire value. Opaque MEOS types "
41+
"(Temporal*, STBox, Set, …) MUST be supplied as the encoded "
42+
"wire form named in the catalog's `decode_per_param`."
43+
),
44+
)
45+
46+
47+
class InvokeResponse(BaseModel):
48+
"""Body of a POST /functions/{name} response."""
49+
50+
result: Any = Field(..., description="The function return value.")
51+
encoding: str | None = Field(
52+
None,
53+
description=(
54+
"Encoding the result is delivered in. `null` for scalar "
55+
"(int / float / str / bool) returns; one of `mfjson` / "
56+
"`text` / `wkb` / `hexwkb` for serialised MEOS objects."
57+
),
58+
)
59+
60+
61+
@router.post(
62+
"/{name}",
63+
response_model=InvokeResponse,
64+
summary="Invoke a dispatcher-exposable MEOS function",
65+
)
66+
def invoke(name: str, body: InvokeRequest, request: Request) -> InvokeResponse:
67+
dispatcher = request.app.state.dispatcher
68+
codec = request.app.state.codec
69+
70+
if not dispatcher.has(name):
71+
raise HTTPException(
72+
status_code=404,
73+
detail=(
74+
f"Function `{name}` is not in the dispatcher catalog. "
75+
f"GET /catalog lists available functions."
76+
),
77+
)
78+
79+
sig = dispatcher.signature(name)
80+
81+
# 1. Decode opaque MEOS-type parameters via the codec, leaving
82+
# scalar parameters untouched.
83+
decoded: dict[str, Any] = {}
84+
for key, value in body.params.items():
85+
encoding = sig.decode_per_param.get(key)
86+
if encoding:
87+
try:
88+
decoded[key] = codec.decode(encoding, value)
89+
except KeyError as e:
90+
raise HTTPException(
91+
status_code=400,
92+
detail=(
93+
f"Parameter `{key}` claims encoding `{encoding}` "
94+
f"but the WireCodec has no decoder for it."
95+
),
96+
) from e
97+
else:
98+
decoded[key] = value
99+
100+
# 2. Dispatch — surfaces KeyError (unknown name, caught above) and
101+
# TypeError (missing / extra params) as 400 errors.
102+
try:
103+
result = dispatcher.dispatch(name, decoded)
104+
except TypeError as e:
105+
raise HTTPException(status_code=400, detail=str(e)) from e
106+
107+
# 3. Encode the result if the catalog labels it with an encoding.
108+
if sig.encode_return:
109+
try:
110+
wire_result = codec.encode(sig.encode_return, result)
111+
except KeyError as e:
112+
raise HTTPException(
113+
status_code=500,
114+
detail=(
115+
f"Result claims encoding `{sig.encode_return}` "
116+
f"but the WireCodec has no encoder for it."
117+
),
118+
) from e
119+
return InvokeResponse(result=wire_result, encoding=sig.encode_return)
120+
121+
return InvokeResponse(result=result, encoding=None)

0 commit comments

Comments
 (0)