Skip to content

Commit 6622659

Browse files
Add the catalog-driven dispatcher substrate for generated MEOS endpoints
The mobilityapi/ package is a FastAPI dispatcher framework that invokes MEOS functions from the vendored MEOS-API catalog: a Dispatcher that resolves function signatures from vendor/meos-api/meos-idl.json, a WireCodec that decodes and encodes per-parameter JSON, pluggable resolvers, and FastAPI routers (/catalog for introspection, /functions to invoke). vendor/meos-api/ carries the pinned MEOS-API artefacts the dispatcher reads. Tests cover the dispatcher, wire codec, resolvers, and the FastAPI app through stubs, so they run without a live MEOS.
1 parent 636f459 commit 6622659

17 files changed

Lines changed: 80075 additions & 0 deletions

‎mobilityapi/__init__.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""MobilityAPI catalog-driven dispatcher package.
2+
3+
The MobilityAPI ingestion plan (docs/MEOS_API_INGESTION_PLAN.md) calls for
4+
replacing the hand-written MEOS-dispatching endpoint modules with thin
5+
dispatchers driven by the vendored MEOS-API catalog. This package provides
6+
the three foundation pieces the migrating endpoints share:
7+
8+
- ``Dispatcher`` — catalog-driven function lookup + invocation.
9+
- ``resolvers`` — pick the MEOS function implementation
10+
(production: PyMEOS; tests: explicit stubs).
11+
- ``wire`` — decode HTTP wire values to PyMEOS objects;
12+
encode PyMEOS results back to wire values.
13+
14+
Existing hand-written endpoints remain unchanged until they are migrated
15+
module-by-module in follow-up PRs.
16+
"""
17+
18+
from .dispatcher import Dispatcher, FunctionSignature
19+
from .resolvers import stub_resolver, pymeos_resolver, default_resolver
20+
from .wire import (
21+
WireCodec, stub_codec, pymeos_codec,
22+
ENCODING_MFJSON, ENCODING_TEXT, ENCODING_WKB, ENCODING_HEXWKB,
23+
)
24+
25+
__all__ = [
26+
"create_app",
27+
"Dispatcher", "FunctionSignature",
28+
"stub_resolver", "pymeos_resolver", "default_resolver",
29+
"WireCodec", "stub_codec", "pymeos_codec",
30+
"ENCODING_MFJSON", "ENCODING_TEXT", "ENCODING_WKB", "ENCODING_HEXWKB",
31+
]
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/dispatcher.py‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Catalog-driven dispatcher for MEOS functions.
2+
3+
Reads the vendored MEOS-API catalog (``vendor/meos-api/meos-idl.json``,
4+
produced by the MEOS-API ``run.py`` against MobilityDB master headers) and
5+
exposes a single ``dispatch(function_name, params) -> Any`` entry point.
6+
7+
When a MEOS-API enriched catalog (with ``network``/``wire``/``api`` fields,
8+
authored by ``parser/enrich.py`` on MEOS-API PR #4) is the source, the
9+
dispatcher uses the richer per-parameter decode/encode metadata. When only
10+
the bare catalog is available, it falls back to the function signature
11+
itself.
12+
13+
The dispatcher does NOT invoke PyMEOS directly inside its core logic —
14+
PyMEOS is injected as a *resolver* callable so the same dispatcher can be
15+
unit-tested with stubs. In production, the resolver is
16+
``getattr(pymeos.functions, name)`` (PyMEOS's flat function module mirrors
17+
the MEOS C API one-for-one).
18+
19+
Foundation only: this PR ships the loader, the signature model, and the
20+
dispatch entry point with stub-resolver unit tests. The follow-up PRs swap
21+
each of the 5 hand-written ``resource/*`` modules listed in
22+
``docs/MEOS_API_INGESTION_PLAN.md`` (§\"Replace candidates\") to call
23+
``Dispatcher.dispatch`` instead of psycopg2 SQL.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import json
29+
from dataclasses import dataclass, field
30+
from pathlib import Path
31+
from typing import Any, Callable, Iterable
32+
33+
34+
# Default vendored catalog path, resolved relative to the repository root.
35+
_DEFAULT_CATALOG = (
36+
Path(__file__).resolve().parent.parent
37+
/ "vendor" / "meos-api" / "meos-idl.json"
38+
)
39+
40+
41+
@dataclass(frozen=True)
42+
class FunctionSignature:
43+
"""One MEOS function from the catalog, normalised for dispatch."""
44+
45+
name: str
46+
category: str
47+
params: list[dict] = field(default_factory=list)
48+
return_type: str = ""
49+
# Network / wire enrichment (optional; only present on enriched catalog).
50+
exposable: bool = True
51+
decode_per_param: dict[str, str] = field(default_factory=dict)
52+
encode_return: str | None = None
53+
description: str = ""
54+
55+
@classmethod
56+
def from_catalog_entry(cls, entry: dict) -> "FunctionSignature":
57+
network = entry.get("network", {})
58+
wire = entry.get("wire", {})
59+
60+
decode_per_param: dict[str, str] = {}
61+
if wire.get("params"):
62+
for p in wire["params"]:
63+
if p.get("kind") == "serialized" and p.get("decode"):
64+
decode_per_param[p["name"]] = p["decode"]
65+
elif p.get("kind") == "array" and p.get("element", {}).get("decode"):
66+
decode_per_param[p["name"]] = p["element"]["decode"]
67+
68+
encode_return: str | None = None
69+
if wire.get("result", {}).get("kind") == "serialized":
70+
encode_return = wire["result"].get("encode")
71+
72+
return cls(
73+
name=entry["name"],
74+
category=entry.get("category", "uncategorised"),
75+
params=entry.get("params", []),
76+
return_type=entry.get("return_type", ""),
77+
exposable=bool(network.get("exposable", True)),
78+
decode_per_param=decode_per_param,
79+
encode_return=encode_return,
80+
description=entry.get("doc", "") or entry.get("description", ""),
81+
)
82+
83+
84+
class Dispatcher:
85+
"""Catalog-driven MEOS function dispatcher."""
86+
87+
def __init__(
88+
self,
89+
catalog_path: Path | str | None = None,
90+
resolver: Callable[[str], Callable[..., Any]] | None = None,
91+
) -> None:
92+
"""Construct a dispatcher.
93+
94+
:param catalog_path: Path to ``meos-idl.json``; defaults to the
95+
vendored copy at ``vendor/meos-api/meos-idl.json``.
96+
:param resolver: Callable mapping a MEOS function name to the
97+
Python callable that implements it. In production this is
98+
``lambda n: getattr(pymeos.functions, n)``. In unit tests it
99+
can be a stub registry. Defaults to a stub that raises
100+
``NotImplementedError`` — the caller must supply a real
101+
resolver before ``dispatch`` is called.
102+
"""
103+
path = Path(catalog_path) if catalog_path else _DEFAULT_CATALOG
104+
self._catalog_path = path
105+
self._signatures: dict[str, FunctionSignature] = {}
106+
self._load(path)
107+
self._resolver = resolver or self._stub_resolver
108+
109+
# -- catalog ----------------------------------------------------------------
110+
111+
def _load(self, path: Path) -> None:
112+
if not path.exists():
113+
raise FileNotFoundError(
114+
f"MEOS-API catalog not found at {path}. Run "
115+
f"`make vendor-meos-api` to (re-)populate vendor/meos-api/."
116+
)
117+
with path.open() as f:
118+
catalog = json.load(f)
119+
120+
for entry in catalog.get("functions", []):
121+
sig = FunctionSignature.from_catalog_entry(entry)
122+
if sig.exposable:
123+
self._signatures[sig.name] = sig
124+
125+
def signature(self, name: str) -> FunctionSignature:
126+
try:
127+
return self._signatures[name]
128+
except KeyError:
129+
raise KeyError(
130+
f"Unknown MEOS function `{name}` — either it does not exist "
131+
f"in the vendored catalog or it is not exposable."
132+
)
133+
134+
def signatures(self) -> Iterable[FunctionSignature]:
135+
return self._signatures.values()
136+
137+
def has(self, name: str) -> bool:
138+
return name in self._signatures
139+
140+
def __len__(self) -> int:
141+
return len(self._signatures)
142+
143+
# -- dispatch ---------------------------------------------------------------
144+
145+
@staticmethod
146+
def _stub_resolver(name: str) -> Callable[..., Any]:
147+
def _raise(*_a, **_kw): # pragma: no cover - intentional stub
148+
raise NotImplementedError(
149+
f"Dispatcher has no resolver wired in for `{name}`. Pass a "
150+
f"resolver= argument to Dispatcher(...)."
151+
)
152+
return _raise
153+
154+
def dispatch(self, function_name: str, params: dict) -> Any:
155+
"""Invoke the MEOS function named ``function_name`` with ``params``.
156+
157+
``params`` is a JSON-like dict whose keys match the function's
158+
parameter names (per the catalog). Each parameter is passed through
159+
unchanged to the resolver-returned callable; the caller is
160+
responsible for decoding opaque types (e.g. constructing
161+
``pymeos.TGeomPoint`` from MF-JSON) before calling ``dispatch``.
162+
163+
Encoding the return value is also left to the caller — the
164+
dispatcher returns whatever the resolver-returned callable returns.
165+
166+
The catalog signature is used only for validation:
167+
168+
* unknown function name → ``KeyError``
169+
* mismatched parameter set → ``TypeError`` with a helpful message
170+
"""
171+
sig = self.signature(function_name)
172+
self._validate_params(sig, params)
173+
fn = self._resolver(function_name)
174+
return fn(**params)
175+
176+
@staticmethod
177+
def _validate_params(sig: FunctionSignature, params: dict) -> None:
178+
expected = {p["name"] for p in sig.params}
179+
provided = set(params.keys())
180+
missing = expected - provided
181+
unexpected = provided - expected
182+
if missing or unexpected:
183+
details = []
184+
if missing:
185+
details.append(f"missing: {sorted(missing)}")
186+
if unexpected:
187+
details.append(f"unexpected: {sorted(unexpected)}")
188+
raise TypeError(
189+
f"`{sig.name}` parameter set mismatch — "
190+
+ "; ".join(details)
191+
+ f". Expected: {sorted(expected)}"
192+
)

0 commit comments

Comments
 (0)