Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6180443327 | |||
| 1b26021725 | |||
| a74e3d0d01 | |||
| 31bf1b1b6b | |||
| 7b4583f305 | |||
| fc8346fcda | |||
| 40d91bc52b | |||
| 2ac342240b |
3
docs/openapi_first/client.md
Normal file
3
docs/openapi_first/client.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Client
|
||||
|
||||
::: openapi_first.client
|
||||
@@ -42,6 +42,7 @@ nav:
|
||||
- OpenAPI-First App: openapi_first/app.md
|
||||
- Route Binder: openapi_first/binder.md
|
||||
- Spec Loaders: openapi_first/loader.md
|
||||
- Client: openapi_first/client.md
|
||||
|
||||
- CLI:
|
||||
- Home: openapi_first/cli.md
|
||||
|
||||
@@ -17,11 +17,12 @@ convenience facades.
|
||||
Architecture Overview
|
||||
----------------------------------------------------------------------
|
||||
|
||||
The library is structured around three core responsibilities:
|
||||
The library is structured around four core responsibilities:
|
||||
|
||||
- loader: load and validate OpenAPI 3.x specifications (JSON/YAML)
|
||||
- binder: bind OpenAPI operations to FastAPI routes via operationId
|
||||
- app: OpenAPI-first FastAPI application bootstrap
|
||||
- client: OpenAPI-first HTTP client driven by the same specification
|
||||
- errors: explicit error hierarchy for contract violations
|
||||
|
||||
The package root acts as a **namespace**, not a facade. Consumers are
|
||||
@@ -40,7 +41,8 @@ Or with Poetry:
|
||||
poetry add openapi-first
|
||||
|
||||
Runtime dependencies are intentionally minimal:
|
||||
- fastapi
|
||||
- fastapi (server-side)
|
||||
- httpx (client-side)
|
||||
- openapi-spec-validator
|
||||
- pyyaml (optional, for YAML specs)
|
||||
|
||||
@@ -48,7 +50,7 @@ The ASGI server (e.g., uvicorn) is an application-level dependency and is
|
||||
not bundled with this library.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Basic Usage
|
||||
Server-Side Usage (OpenAPI → FastAPI)
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Minimal OpenAPI-first FastAPI application:
|
||||
@@ -57,7 +59,7 @@ Minimal OpenAPI-first FastAPI application:
|
||||
import my_service.routes as routes
|
||||
|
||||
api = app.OpenAPIFirstApp(
|
||||
openapi_path="openapi.json",
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=routes,
|
||||
title="My Service",
|
||||
version="1.0.0",
|
||||
@@ -81,6 +83,56 @@ OpenAPI snippet:
|
||||
"200":
|
||||
description: OK
|
||||
|
||||
The binder guarantees:
|
||||
- Every OpenAPI operationId has exactly one handler
|
||||
- No undocumented routes exist
|
||||
- All mismatches fail at application startup
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Client-Side Usage (OpenAPI → HTTP Client)
|
||||
----------------------------------------------------------------------
|
||||
|
||||
The same OpenAPI specification can be used to construct a strict,
|
||||
operationId-driven HTTP client.
|
||||
|
||||
Client construction:
|
||||
|
||||
from openapi_first.loader import load_openapi
|
||||
from openapi_first.client import OpenAPIClient
|
||||
|
||||
spec = load_openapi("openapi.yaml")
|
||||
|
||||
client = OpenAPIClient(spec)
|
||||
|
||||
Calling operations (operationId is the API):
|
||||
|
||||
response = client.get_health()
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
Path parameters must match the OpenAPI specification exactly:
|
||||
|
||||
response = client.get_item(
|
||||
path_params={"item_id": 1}
|
||||
)
|
||||
|
||||
Request bodies are passed explicitly:
|
||||
|
||||
response = client.create_item(
|
||||
body={"name": "Orange", "price": 0.8}
|
||||
)
|
||||
|
||||
Client guarantees:
|
||||
- One callable per OpenAPI operationId
|
||||
- No hardcoded URLs or HTTP methods in user code
|
||||
- Path and body parameters must match the spec exactly
|
||||
- Invalid or incomplete OpenAPI specs fail at client construction time
|
||||
- No schema inference or mutation is performed
|
||||
|
||||
The client is transport-level only and returns `httpx.Response`
|
||||
objects directly. Response interpretation and validation are left to
|
||||
the consumer or higher-level layers.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Extensibility Model
|
||||
----------------------------------------------------------------------
|
||||
@@ -107,6 +159,7 @@ The supported public API consists of the following top-level modules:
|
||||
- openapi_first.app
|
||||
- openapi_first.binder
|
||||
- openapi_first.loader
|
||||
- openapi_first.client
|
||||
- openapi_first.errors
|
||||
|
||||
Classes and functions should be imported explicitly from these modules.
|
||||
@@ -118,8 +171,8 @@ Design Guarantees
|
||||
|
||||
- OpenAPI is the single source of truth
|
||||
- No undocumented routes can exist
|
||||
- No OpenAPI operation can exist without a handler
|
||||
- All contract violations fail at application startup
|
||||
- No OpenAPI operation can exist without a handler or client callable
|
||||
- All contract violations fail at application startup or client creation
|
||||
- No hidden FastAPI magic or implicit behavior
|
||||
- Deterministic, testable application assembly
|
||||
- CI-friendly failure modes
|
||||
@@ -131,11 +184,13 @@ enforcement over convenience shortcuts.
|
||||
from . import app
|
||||
from . import binder
|
||||
from . import loader
|
||||
from . import client
|
||||
from . import errors
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
"binder",
|
||||
"loader",
|
||||
"client",
|
||||
"errors",
|
||||
]
|
||||
|
||||
176
openapi_first/client.py
Normal file
176
openapi_first/client.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import OpenAPIFirstError
|
||||
|
||||
|
||||
class OpenAPIClientError(OpenAPIFirstError):
|
||||
"""Raised when an OpenAPI client operation fails."""
|
||||
|
||||
|
||||
class OpenAPIClient:
|
||||
"""
|
||||
OpenAPI-first HTTP client (httpx-based).
|
||||
|
||||
- One callable per operationId
|
||||
- Explicit parameters (path, query, headers, body)
|
||||
- No implicit schema inference or mutation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spec: dict[str, Any],
|
||||
base_url: Optional[str] = None,
|
||||
client: Optional[httpx.Client] = None,
|
||||
) -> None:
|
||||
self.spec = spec
|
||||
self.base_url = base_url or self._resolve_base_url(spec)
|
||||
self.client = client or httpx.Client(base_url=self.base_url)
|
||||
|
||||
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
|
||||
self._build_operations()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Public API
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def __getattr__(self, name: str) -> Callable[..., httpx.Response]:
|
||||
try:
|
||||
return self._operations[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f"No such operationId: {name}") from None
|
||||
|
||||
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
|
||||
return dict(self._operations)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internal mechanics
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _resolve_base_url(self, spec: dict[str, Any]) -> str:
|
||||
servers = spec.get("servers")
|
||||
if not servers:
|
||||
raise OpenAPIClientError("No servers defined in OpenAPI spec")
|
||||
|
||||
url = servers[0].get("url")
|
||||
if not url:
|
||||
raise OpenAPIClientError("Server entry missing 'url'")
|
||||
|
||||
return url.rstrip("/") + "/"
|
||||
|
||||
def _build_operations(self) -> None:
|
||||
paths = self.spec.get("paths", {})
|
||||
if not paths:
|
||||
raise OpenAPIClientError("OpenAPI spec contains no paths")
|
||||
|
||||
for path, path_item in paths.items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {
|
||||
"get", "post", "put", "patch", "delete", "head", "options"
|
||||
}:
|
||||
continue
|
||||
|
||||
operation_id = operation.get("operationId")
|
||||
if not operation_id:
|
||||
raise OpenAPIClientError(
|
||||
f"Missing operationId for {method.upper()} {path}"
|
||||
)
|
||||
|
||||
if operation_id in self._operations:
|
||||
raise OpenAPIClientError(
|
||||
f"Duplicate operationId detected: {operation_id}"
|
||||
)
|
||||
|
||||
self._operations[operation_id] = self._make_operation(
|
||||
method=method.upper(),
|
||||
path=path,
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
def _make_operation(
|
||||
self,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
operation: dict[str, Any],
|
||||
) -> Callable[..., httpx.Response]:
|
||||
request_body = operation.get("requestBody")
|
||||
|
||||
def call(
|
||||
*,
|
||||
path_params: Optional[dict[str, Any]] = None,
|
||||
query: Optional[dict[str, Any]] = None,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
body: Optional[Any] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> httpx.Response:
|
||||
url = self._build_url(path, path_params or {})
|
||||
|
||||
req_headers = headers.copy() if headers else {}
|
||||
|
||||
json_data = None
|
||||
content = None
|
||||
|
||||
if request_body is not None:
|
||||
if body is None:
|
||||
raise OpenAPIClientError(
|
||||
f"Request body required for operation {operation['operationId']}"
|
||||
)
|
||||
|
||||
media_types = request_body.get("content", {})
|
||||
if "application/json" in media_types:
|
||||
json_data = body
|
||||
req_headers.setdefault("Content-Type", "application/json")
|
||||
else:
|
||||
content = body
|
||||
|
||||
response = self.client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
params=query,
|
||||
headers=req_headers,
|
||||
json=json_data,
|
||||
content=content,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
call.__name__ = operation["operationId"]
|
||||
call.__doc__ = self._build_docstring(method, path, operation)
|
||||
|
||||
return call
|
||||
|
||||
def _build_url(self, path: str, path_params: dict[str, Any]) -> str:
|
||||
try:
|
||||
formatted_path = path.format(**path_params)
|
||||
except KeyError as exc:
|
||||
raise OpenAPIClientError(
|
||||
f"Missing path parameter: {exc.args[0]}"
|
||||
) from exc
|
||||
|
||||
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
||||
|
||||
def _build_docstring(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
operation: dict[str, Any],
|
||||
) -> str:
|
||||
lines = [
|
||||
f"{method} {path}",
|
||||
"",
|
||||
operation.get("summary", ""),
|
||||
"",
|
||||
"Parameters:",
|
||||
" path_params: dict | None",
|
||||
" query: dict | None",
|
||||
" headers: dict | None",
|
||||
" body: Any | None",
|
||||
"",
|
||||
"Returns:",
|
||||
" httpx.Response",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
@@ -25,8 +25,6 @@ This module intentionally does NOT:
|
||||
- Perform request/response validation at runtime
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
41
openapi_first/templates/crud_app/data.py
Normal file
41
openapi_first/templates/crud_app/data.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
In-memory mock data store for CRUD example.
|
||||
|
||||
This module intentionally avoids persistence and concurrency guarantees.
|
||||
It is suitable for demos, tests, and scaffolding only.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
_items: Dict[int, dict] = {
|
||||
1: {"id": 1, "name": "Apple", "price": 0.5},
|
||||
2: {"id": 2, "name": "Banana", "price": 0.3},
|
||||
}
|
||||
|
||||
_next_id = 3
|
||||
|
||||
|
||||
def list_items():
|
||||
return list(_items.values())
|
||||
|
||||
|
||||
def get_item(item_id: int):
|
||||
return _items[item_id]
|
||||
|
||||
|
||||
def create_item(payload: dict):
|
||||
global _next_id
|
||||
item = {"id": _next_id, **payload}
|
||||
_items[_next_id] = item
|
||||
_next_id += 1
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: dict):
|
||||
item = {"id": item_id, **payload}
|
||||
_items[item_id] = item
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(item_id: int):
|
||||
del _items[item_id]
|
||||
8
openapi_first/templates/crud_app/main.py
Normal file
8
openapi_first/templates/crud_app/main.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
import routes
|
||||
|
||||
app = OpenAPIFirstApp(
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=routes,
|
||||
title="CRUD Example Service",
|
||||
)
|
||||
115
openapi_first/templates/crud_app/openapi.yaml
Normal file
115
openapi_first/templates/crud_app/openapi.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: CRUD Example Service
|
||||
version: "1.0.0"
|
||||
description: Minimal OpenAPI-first CRUD service with in-memory mock data.
|
||||
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: list_items
|
||||
summary: List all items
|
||||
responses:
|
||||
"200":
|
||||
description: List of items
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Item"
|
||||
|
||||
post:
|
||||
operationId: create_item
|
||||
summary: Create a new item
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Item created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
|
||||
/items/{item_id}:
|
||||
get:
|
||||
operationId: get_item
|
||||
summary: Get item by ID
|
||||
parameters:
|
||||
- name: item_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Item found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
|
||||
put:
|
||||
operationId: update_item
|
||||
summary: Update an item
|
||||
parameters:
|
||||
- name: item_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemCreate"
|
||||
responses:
|
||||
"200":
|
||||
description: Item updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
|
||||
delete:
|
||||
operationId: delete_item
|
||||
summary: Delete an item
|
||||
parameters:
|
||||
- name: item_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"204":
|
||||
description: Item deleted
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Item:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
format: float
|
||||
required: [id, name, price]
|
||||
|
||||
ItemCreate:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
format: float
|
||||
required: [name, price]
|
||||
50
openapi_first/templates/crud_app/routes.py
Normal file
50
openapi_first/templates/crud_app/routes.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
CRUD route handlers bound via OpenAPI operationId.
|
||||
|
||||
These handlers explicitly control HTTP status codes to ensure
|
||||
runtime behavior matches the OpenAPI contract.
|
||||
"""
|
||||
|
||||
from fastapi import Response, HTTPException
|
||||
|
||||
from data import (
|
||||
list_items as _list_items,
|
||||
get_item as _get_item,
|
||||
create_item as _create_item,
|
||||
update_item as _update_item,
|
||||
delete_item as _delete_item,
|
||||
)
|
||||
|
||||
|
||||
def list_items():
|
||||
return _list_items()
|
||||
|
||||
|
||||
def get_item(item_id: int):
|
||||
try:
|
||||
return _get_item(item_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def create_item(payload: dict, response: Response):
|
||||
item = _create_item(payload)
|
||||
response.status_code = 201
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: dict):
|
||||
try:
|
||||
return _update_item(item_id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def delete_item(item_id: int, response: Response):
|
||||
try:
|
||||
_delete_item(item_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
response.status_code = 204
|
||||
return None
|
||||
116
openapi_first/templates/crud_app/test_crud_app.py
Normal file
116
openapi_first/templates/crud_app/test_crud_app.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
End-to-end tests for the OpenAPI-first CRUD example app.
|
||||
|
||||
These tests validate that all CRUD operations behave correctly
|
||||
against the in-memory mock data store.
|
||||
|
||||
The tests assume:
|
||||
- OpenAPI-first route binding
|
||||
- In-memory storage (no persistence guarantees)
|
||||
- Deterministic behavior in a single process
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from openapi_first.loader import load_openapi
|
||||
from openapi_first.client import OpenAPIClient
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
spec = load_openapi("openapi.yaml")
|
||||
client = OpenAPIClient(
|
||||
spec=spec,
|
||||
base_url="http://testserver",
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
def test_list_items_initial():
|
||||
"""Initial items should be present."""
|
||||
response = client.list_items()
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 2
|
||||
|
||||
ids = {item["id"] for item in data}
|
||||
assert 1 in ids
|
||||
assert 2 in ids
|
||||
|
||||
|
||||
def test_get_item():
|
||||
"""Existing item should be retrievable by ID."""
|
||||
response = client.get_item(
|
||||
path_params={"item_id": 1}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
item = response.json()
|
||||
assert item["id"] == 1
|
||||
assert "name" in item
|
||||
assert "price" in item
|
||||
|
||||
|
||||
def test_create_item():
|
||||
"""Creating a new item should return the created entity."""
|
||||
payload = {
|
||||
"name": "Orange",
|
||||
"price": 0.8,
|
||||
}
|
||||
|
||||
response = client.create_item(
|
||||
body=payload
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
item = response.json()
|
||||
assert "id" in item
|
||||
assert item["name"] == payload["name"]
|
||||
assert item["price"] == payload["price"]
|
||||
|
||||
# Verify it appears in list
|
||||
list_response = client.list_items()
|
||||
ids = {i["id"] for i in list_response.json()}
|
||||
assert item["id"] in ids
|
||||
|
||||
|
||||
def test_update_item():
|
||||
"""Updating an item should replace its values."""
|
||||
payload = {
|
||||
"name": "Green Apple",
|
||||
"price": 0.6,
|
||||
}
|
||||
|
||||
response = client.update_item(
|
||||
path_params={"item_id": 1},
|
||||
body=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
item = response.json()
|
||||
assert item["id"] == 1
|
||||
assert item["name"] == payload["name"]
|
||||
assert item["price"] == payload["price"]
|
||||
|
||||
# Verify persisted update
|
||||
get_response = client.get_item(
|
||||
path_params={"item_id": 1}
|
||||
)
|
||||
updated = get_response.json()
|
||||
assert updated["name"] == payload["name"]
|
||||
assert updated["price"] == payload["price"]
|
||||
|
||||
|
||||
def test_delete_item():
|
||||
"""Deleting an item should remove it from the store."""
|
||||
response = client.delete_item(
|
||||
path_params={"item_id": 2}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify deletion
|
||||
list_response = client.list_items()
|
||||
ids = {item["id"] for item in list_response.json()}
|
||||
assert 2 not in ids
|
||||
44
openapi_first/templates/model_app/data.py
Normal file
44
openapi_first/templates/model_app/data.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
In-memory data store using Pydantic models.
|
||||
|
||||
This module is NOT thread-safe and is intended for demos and scaffolds only.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from models import Item, ItemCreate
|
||||
|
||||
_items: Dict[int, Item] = {
|
||||
1: Item(id=1, name="Apple", price=0.5),
|
||||
2: Item(id=2, name="Banana", price=0.3),
|
||||
}
|
||||
|
||||
_next_id = 3
|
||||
|
||||
|
||||
def list_items() -> list[Item]:
|
||||
return list(_items.values())
|
||||
|
||||
|
||||
def get_item(item_id: int) -> Item:
|
||||
return _items[item_id]
|
||||
|
||||
|
||||
def create_item(payload: ItemCreate) -> Item:
|
||||
global _next_id
|
||||
item = Item(id=_next_id, **payload.model_dump())
|
||||
_items[_next_id] = item
|
||||
_next_id += 1
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: ItemCreate) -> Item:
|
||||
if item_id not in _items:
|
||||
raise KeyError(item_id)
|
||||
item = Item(id=item_id, **payload.model_dump())
|
||||
_items[item_id] = item
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(item_id: int) -> None:
|
||||
del _items[item_id]
|
||||
8
openapi_first/templates/model_app/main.py
Normal file
8
openapi_first/templates/model_app/main.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from openapi_first.app import OpenAPIFirstApp
|
||||
import routes
|
||||
|
||||
app = OpenAPIFirstApp(
|
||||
openapi_path="openapi.yaml",
|
||||
routes_module=routes,
|
||||
title="Model CRUD Example Service",
|
||||
)
|
||||
18
openapi_first/templates/model_app/models.py
Normal file
18
openapi_first/templates/model_app/models.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Pydantic domain models for the CRUD example.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ItemBase(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
class ItemCreate(ItemBase):
|
||||
pass
|
||||
|
||||
|
||||
class Item(ItemBase):
|
||||
id: int
|
||||
116
openapi_first/templates/model_app/openapi.yaml
Normal file
116
openapi_first/templates/model_app/openapi.yaml
Normal file
@@ -0,0 +1,116 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Model CRUD Example Service
|
||||
version: "1.0.0"
|
||||
description: OpenAPI-first CRUD service with Pydantic models.
|
||||
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: list_items
|
||||
responses:
|
||||
"200":
|
||||
description: List of items
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Item"
|
||||
|
||||
post:
|
||||
operationId: create_item
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Item created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
|
||||
/items/{item_id}:
|
||||
get:
|
||||
operationId: get_item
|
||||
parameters:
|
||||
- name: item_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Item found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
"404":
|
||||
description: Item not found
|
||||
|
||||
put:
|
||||
operationId: update_item
|
||||
parameters:
|
||||
- name: item_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemCreate"
|
||||
responses:
|
||||
"200":
|
||||
description: Item updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Item"
|
||||
"404":
|
||||
description: Item not found
|
||||
|
||||
delete:
|
||||
operationId: delete_item
|
||||
parameters:
|
||||
- name: item_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"204":
|
||||
description: Item deleted
|
||||
"404":
|
||||
description: Item not found
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Item:
|
||||
type: object
|
||||
required: [id, name, price]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
ItemCreate:
|
||||
type: object
|
||||
required: [name, price]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
format: float
|
||||
48
openapi_first/templates/model_app/routes.py
Normal file
48
openapi_first/templates/model_app/routes.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
CRUD route handlers bound via OpenAPI operationId.
|
||||
"""
|
||||
|
||||
from fastapi import Response, HTTPException
|
||||
|
||||
from models import ItemCreate
|
||||
from data import (
|
||||
list_items as _list_items,
|
||||
get_item as _get_item,
|
||||
create_item as _create_item,
|
||||
update_item as _update_item,
|
||||
delete_item as _delete_item,
|
||||
)
|
||||
|
||||
|
||||
def list_items():
|
||||
return _list_items()
|
||||
|
||||
|
||||
def get_item(item_id: int):
|
||||
try:
|
||||
return _get_item(item_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def create_item(payload: ItemCreate, response: Response):
|
||||
item = _create_item(payload)
|
||||
response.status_code = 201
|
||||
return item
|
||||
|
||||
|
||||
def update_item(item_id: int, payload: ItemCreate):
|
||||
try:
|
||||
return _update_item(item_id, payload)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
|
||||
def delete_item(item_id: int, response: Response):
|
||||
try:
|
||||
_delete_item(item_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
response.status_code = 204
|
||||
return None
|
||||
117
openapi_first/templates/model_app/test_model_app.py
Normal file
117
openapi_first/templates/model_app/test_model_app.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
End-to-end tests for the OpenAPI-first model CRUD example app.
|
||||
|
||||
These tests validate that all CRUD operations behave correctly
|
||||
against the in-memory mock data store using Pydantic models.
|
||||
|
||||
The tests assume:
|
||||
- OpenAPI-first route binding
|
||||
- Pydantic model validation
|
||||
- In-memory storage (no persistence guarantees)
|
||||
- Deterministic behavior in a single process
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from openapi_first.loader import load_openapi
|
||||
from openapi_first.client import OpenAPIClient
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
spec = load_openapi("openapi.yaml")
|
||||
client = OpenAPIClient(
|
||||
spec=spec,
|
||||
base_url="http://testserver",
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
def test_list_items_initial():
|
||||
"""Initial items should be present."""
|
||||
response = client.list_items()
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 2
|
||||
|
||||
ids = {item["id"] for item in data}
|
||||
assert 1 in ids
|
||||
assert 2 in ids
|
||||
|
||||
|
||||
def test_get_item():
|
||||
"""Existing item should be retrievable by ID."""
|
||||
response = client.get_item(
|
||||
path_params={"item_id": 1}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
item = response.json()
|
||||
assert item["id"] == 1
|
||||
assert isinstance(item["name"], str)
|
||||
assert isinstance(item["price"], float)
|
||||
|
||||
|
||||
def test_create_item():
|
||||
"""Creating a new item should return the created entity."""
|
||||
payload = {
|
||||
"name": "Orange",
|
||||
"price": 0.8,
|
||||
}
|
||||
|
||||
response = client.create_item(
|
||||
body=payload
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
item = response.json()
|
||||
assert "id" in item
|
||||
assert item["name"] == payload["name"]
|
||||
assert item["price"] == payload["price"]
|
||||
|
||||
# Verify it appears in list
|
||||
list_response = client.list_items()
|
||||
ids = {i["id"] for i in list_response.json()}
|
||||
assert item["id"] in ids
|
||||
|
||||
|
||||
def test_update_item():
|
||||
"""Updating an item should replace its values."""
|
||||
payload = {
|
||||
"name": "Green Apple",
|
||||
"price": 0.6,
|
||||
}
|
||||
|
||||
response = client.update_item(
|
||||
path_params={"item_id": 1},
|
||||
body=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
item = response.json()
|
||||
assert item["id"] == 1
|
||||
assert item["name"] == payload["name"]
|
||||
assert item["price"] == payload["price"]
|
||||
|
||||
# Verify persisted update
|
||||
get_response = client.get_item(
|
||||
path_params={"item_id": 1}
|
||||
)
|
||||
updated = get_response.json()
|
||||
assert updated["name"] == payload["name"]
|
||||
assert updated["price"] == payload["price"]
|
||||
|
||||
|
||||
def test_delete_item():
|
||||
"""Deleting an item should remove it from the store."""
|
||||
response = client.delete_item(
|
||||
path_params={"item_id": 2}
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify deletion
|
||||
list_response = client.list_items()
|
||||
ids = {item["id"] for item in list_response.json()}
|
||||
assert 2 not in ids
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "openapi-first"
|
||||
version = "0.0.1"
|
||||
version = "0.0.3"
|
||||
description = "Strict OpenAPI-first application bootstrap for FastAPI."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
fastapi==0.128.0,
|
||||
openapi-spec-validator==0.7.2,
|
||||
pyyaml==6.0.3,
|
||||
openapi-spec-validator==0.7.2
|
||||
pyyaml==6.0.3
|
||||
uvicorn==0.40.0
|
||||
pydantic==2.12.5
|
||||
httpx==0.28.1
|
||||
|
||||
# Test Packages
|
||||
pytest==7.4.0
|
||||
|
||||
Reference in New Issue
Block a user