Skip to content

Commit 06f2c38

Browse files
committed
Merge FR-008 §2.3 — Python router_generator (FastAPI APIRouter)
NEW generator emits <entity_snake>_router.py per entity with source.rdb @kind=table: - FastAPI APIRouter with prefix /api/<entity-plural> - 5 verbs (GET list, GET by id, POST 201, PATCH+PUT, DELETE 204) matching the cross-port API contract - ?limit=N&offset=N pagination (defaults 50/0) - ?sort=<field>:asc|desc with module-level _SORT_ALLOWLIST + 400 envelope - ?withCount=1 returns {"rows": ..., "total": N}; default returns bare list - 404 envelope: {"error": "not_found"} - Repository Protocol interface (consumer overrides via Depends + app.dependency_overrides) Skip @kind=view (read-only) + @kind=storedProc (separate shape). Deferred to KNOWN_GAPS.md: filter operators, int-PK assumption, dto=dict[str,Any] (cross-file import threading needs per-target output-dir infrastructure Python codegen doesn't have yet — DTO Protocol shape preserved). 5 new tests; codegen suite 29→34. Mypy strict-mode clean across all 21 codegen files + new test file. Generated output compiles cleanly under Python 3.11+. Full Python suite green (modulo 1 pre-existing unrelated doc-common-attrs failure). Docs: api-contract.md Python row now shows "shipped"; ports/python.md gains router-generator section + universal client (React/Angular) hookup note. All 5 ports (TS / Java / Kotlin / C# / Python) now ship REST route codegen conforming to the cross-port API contract. FR-008 §2.6 (cross-port conformance corpus) is next.
2 parents d85baf6 + a306058 commit 06f2c38

5 files changed

Lines changed: 519 additions & 1 deletion

File tree

docs/features/api-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ retryable / log-only.
170170
| C# | shipped — `MetaObjects.Codegen` `RoutesGenerator` → ASP.NET Minimal API | `MapGet` / `MapPost` / `MapPut` / `MapDelete` mounted under `apiPrefix`; full CRUD. |
171171
| Java | shipped — `metaobjects-codegen-spring` `SpringControllerGenerator` + `SpringDtoGenerator` + `SpringRepositoryGenerator` → Spring `@RestController` (Spring Boot 3.x / Spring Web MVC) | One controller per writable entity (`source.rdb @kind="table"`); 5 CRUD endpoints (GET list / GET by id / POST / PATCH + PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Java 21 record DTOs for request/response; a stubbed `<Entity>Repository` interface the consumer implements against their persistence layer (JPA / jOOQ / JDBC). Filter operators (`eq/ne/...`) deferred — see the module's `KNOWN_GAPS.md`. |
172172
| Kotlin | shipped — `metaobjects-codegen-kotlin` `KotlinSpringControllerGenerator` → Spring `@RestController` | One controller per writable entity (`source.rdb @kind="table"`); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Filter operators (`eq/ne/...`) deferred. |
173-
| Python | planned | Hand-write a FastAPI router matching the URL grammar. Entity-model codegen ships; persistence + migration in progress. |
173+
| Python | shipped — `metaobjects.codegen.generators.router_generator` FastAPI `APIRouter` | One router per writable entity (`source.rdb @kind="table"`); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Consumer wires the repository via FastAPI `app.dependency_overrides`; the generator emits a `Protocol` interface so the persistence layer (SQLAlchemy / asyncpg / etc.) is the consumer's choice. Filter operators (`eq/ne/...`) deferred — see the module's `KNOWN_GAPS.md`. |
174174

175175
## Hand-writing a conforming controller
176176

docs/ports/python.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,62 @@ class Author:
7373
bio: Optional[str] = None
7474
```
7575

76+
The router generator emits one FastAPI `APIRouter` per writable entity
77+
(`source.rdb` with `@kind="table"`):
78+
79+
```python
80+
# generated/acme/blog/author_router.py (excerpt)
81+
router = APIRouter(prefix="/api/authors", tags=["authors"])
82+
83+
class AuthorRepository(Protocol):
84+
def list(self, limit: int, offset: int, sort: _SortClause | None) -> list[Any]: ...
85+
def count(self) -> int: ...
86+
def find_by_id(self, id: int) -> Any | None: ...
87+
def create(self, dto: Any) -> Any: ...
88+
def update(self, id: int, dto: Any) -> Any | None: ...
89+
def delete(self, id: int) -> bool: ...
90+
91+
def get_repository() -> AuthorRepository:
92+
raise NotImplementedError("Override get_repository via FastAPI dependency_overrides")
93+
94+
@router.get("") # list with ?limit / ?offset / ?sort / ?withCount=1
95+
@router.get("/{author_id}")
96+
@router.post("", status_code=status.HTTP_201_CREATED)
97+
@router.patch("/{author_id}")
98+
@router.put("/{author_id}")
99+
@router.delete("/{author_id}", status_code=status.HTTP_204_NO_CONTENT)
100+
```
101+
102+
The router conforms to the cross-port API contract
103+
([`docs/features/api-contract.md`](../features/api-contract.md)):
104+
`?withCount=1` returns `{"rows", "total"}`; `?sort=field:asc|desc` uses
105+
a static per-entity allowlist (HTTP 400 envelope on unknown field); 404
106+
envelope is `{"error": "not_found"}`. Filter operators
107+
(`eq` / `ne` / ...) are a known gap — see
108+
[`server/python/src/metaobjects/codegen/KNOWN_GAPS.md`](../../server/python/src/metaobjects/codegen/KNOWN_GAPS.md).
109+
110+
Wire the router into your consumer FastAPI app:
111+
112+
```python
113+
from fastapi import FastAPI
114+
from acme.blog.author_router import router as author_router, get_repository
115+
from my_app.persistence import SqlAlchemyAuthorRepository
116+
117+
app = FastAPI()
118+
app.include_router(author_router)
119+
app.dependency_overrides[get_repository] = lambda: SqlAlchemyAuthorRepository(session)
120+
```
121+
122+
### Universal browser-client hookup (React / Angular 18)
123+
124+
The router conforms to the same URL grammar as every other backend port,
125+
so the universal browser client — React/TanStack today, Angular 18 once
126+
FR-008 §2.5 lands — works against a FastAPI backend with no FastAPI-
127+
specific client code. The same generated TanStack hooks (or Angular
128+
services) that talk to a TS Fastify, Java Spring, Kotlin Ktor, or C#
129+
ASP.NET backend will talk to this FastAPI router; the only consumer
130+
wiring is the `EntityFetcher` base URL + auth.
131+
76132
## Use
77133

78134
The loader API is symmetric with the other ports — `from_directory` /
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Python codegen — known gaps
2+
3+
This document tracks deliberate Day-1 deferrals in the Python codegen
4+
(`metaobjects.codegen.generators`).
5+
6+
## Filter operators (`eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `in` / `like` / `isNull`)
7+
8+
**Status:** deferred (`router_generator` only). Matches the Java / Kotlin / C#
9+
Day-1 deferral pattern.
10+
11+
The cross-port REST API contract
12+
([`docs/features/api-contract.md`](../../../../../docs/features/api-contract.md))
13+
describes a bracketed query-string grammar (`filter[<field>][<op>]=<value>`)
14+
with eight operators, gated per field subtype. The TypeScript reference
15+
port ships full support via `parseFilterParams` in
16+
`@metaobjectsdev/runtime-ts/drizzle-fastify`.
17+
18+
The Python `router_generator` does **not** parse this grammar at the
19+
router layer in Day 1. Only `sort`, `limit`, `offset`, and `withCount`
20+
are honoured. A request containing `filter[...]=...` parameters is
21+
currently silently ignored by the generated router. Consumers that need
22+
filter support today must add their own query-string handling via a
23+
FastAPI dependency that reads `request.query_params` and pass through to
24+
the repository.
25+
26+
**When it ships:** a future `filter_allowlist_generator` will emit a
27+
static `<Entity>FilterAllowlist` per entity (mirroring TS Project D +
28+
the cross-port allowlist shape), and the generated `list_*()` handler
29+
will delegate to a `parse_filter_qs(qs, allowlist)` helper that returns
30+
either a Pydantic-validated filter object or a typed query-builder
31+
expression for the repository to apply.
32+
33+
## Single-field, `int`-typed primary keys only
34+
35+
**Status:** assumption baked into Day 1.
36+
37+
The generated router assumes the entity's primary key is a single field
38+
of type `int` (the canonical `BaseEntity` convention across the shared
39+
corpus). The path-parameter type is hard-coded to `int` (e.g.
40+
`def get_author(author_id: int, ...)`); the `find_by_id` / `update` /
41+
`delete` repository methods take `id: int`.
42+
43+
Composite PKs would require a URL grammar for composite ids
44+
(`/api/<entity>/{id_a}_{id_b}` or similar) that the cross-port contract
45+
has not yet specified. Entities with non-`int` single-field PKs (e.g.
46+
`UUID`) still generate, but the `: int` typing in the generated code
47+
will need a hand-edit until typed-PK threading lands in the generator.
48+
49+
**Why deferred:** non-`int` PKs are uncommon and composite PKs are
50+
rarer still. Adding generic PK-type threading to the generator is a
51+
non-trivial spec change that should be discussed at the cross-port
52+
contract level first.
53+
54+
## DTO equals `dict[str, Any]` (no separate request / response Pydantic models)
55+
56+
**Status:** intentional Day-1 simplification.
57+
58+
The generated router takes `dto: dict[str, Any]` for `POST` / `PATCH` /
59+
`PUT` request bodies and returns `Any` for responses. The repository
60+
`Protocol` likewise uses `Any` for the row type.
61+
62+
This keeps the router module decoupled from sibling generated files
63+
(the entity-model generator emits one `@dataclass`/Pydantic model per
64+
entity in a separate file — wiring router→entity-model would require
65+
the path-resolution and import-base machinery that is not yet on the
66+
Python codegen surface). Consumers wanting strong typing can hand-edit
67+
the generated router to import their preferred entity shape.
68+
69+
**When it ships:** once the Python codegen grows per-target output
70+
directories (mirroring the TS `targets` registry — see
71+
`@metaobjectsdev/cli` README), the router generator will emit
72+
`from .<snake>_entity import <Entity>` and use the entity type throughout.

0 commit comments

Comments
 (0)