Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ Served vs built: one flag, two ways to set it.

## What is in the document today

Per route, the code records path, method, a short `summary` / `operationId` derived from the **handler’s** `__name__`, and a simple `200` response placeholder. Bodies, parameters, and components are not yet deep-modeled on the document.
Per route, the code records path, method, a short `summary` / `operationId` derived from the **handler’s** `__name__`, and a simple `200` response placeholder.

**Future work** might add richer request/response metadata when the schema story grows in Rust.
For **`POST`**, **`PUT`**, and **`PATCH`**, you can pass **`body_model=...`** where the value is a **Pydantic v2** `BaseModel` class. OxyRoute calls **`model_json_schema()`** at registration time and stores that JSON Schema under the operation’s **`requestBody` → `content` → `application/json` → `schema`**. Pydantic is optional at runtime: only needed if you use `body_model`. The schema is a plain JSON value in the OpenAPI file (`$defs` and refs may appear as Pydantic emits them).

## See also

Expand Down
12 changes: 12 additions & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from collections.abc import Callable
from typing import Any, TypeVar

Expand Down Expand Up @@ -88,6 +89,7 @@ def post(
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
Expand All @@ -102,6 +104,7 @@ def post(
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
body_model=body_model,
)

def put(
Expand All @@ -115,6 +118,7 @@ def put(
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
Expand All @@ -129,6 +133,7 @@ def put(
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
body_model=body_model,
)

def patch(
Expand All @@ -143,6 +148,7 @@ def patch(
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
Expand All @@ -157,6 +163,7 @@ def patch(
jwt_audience=jwt_audience,
jwt_leeway=jwt_leeway,
jwt_cookie=jwt_cookie,
body_model=body_model,
)

def delete(
Expand Down Expand Up @@ -227,10 +234,14 @@ def _route(
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
jwt_cookie: str | None = None,
body_model: Any | None = None,
) -> Callable[[F], F]:
dlist = _norm_dependencies(dependencies)

def wrap(handler: F) -> F:
body_schema_json: str | None = None
if body_model is not None:
body_schema_json = json.dumps(body_model.model_json_schema())
self._app.add_route(
method,
path,
Expand All @@ -244,6 +255,7 @@ def wrap(handler: F) -> F:
jwt_audience,
jwt_leeway,
jwt_cookie,
body_schema_json,
)
return handler

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dev = [
"pytest>=8",
"httpx>=0.27",
"oxyjwt>=0.2",
"pydantic>=2",
"ruff>=0.8",
]

Expand Down
48 changes: 41 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ pub struct App {
}

impl App {
fn openapi_add_path(oa: &mut serde_json::Value, method: &str, path: &str, op_id: &str) {
fn openapi_add_path(
oa: &mut serde_json::Value,
method: &str,
path: &str,
op_id: &str,
request_schema: Option<serde_json::Value>,
) {
if let Some(paths) = oa
.as_object_mut()
.and_then(|m| m.get_mut("paths"))
Expand All @@ -117,10 +123,28 @@ impl App {
let method_lc = method.to_lowercase();
let path_entry = paths.entry(path).or_insert_with(|| json!({}));
if let Some(obj) = path_entry.as_object_mut() {
obj.insert(
method_lc,
json!({ "summary": op_id, "operationId": op_id, "responses": { "200": { "description": "OK" } } }),
);
let op = if let Some(schema) = request_schema {
json!({
"summary": op_id,
"operationId": op_id,
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": schema
}
}
},
"responses": { "200": { "description": "OK" } }
})
} else {
json!({
"summary": op_id,
"operationId": op_id,
"responses": { "200": { "description": "OK" } }
})
};
obj.insert(method_lc, op);
}
}
}
Expand All @@ -140,7 +164,7 @@ impl App {

/// Paths use **matchit 0.7** style: `/user/:id`. Pass `dependencies=[("x", get_x), ...]`.
#[pyo3(
signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None)
signature = (method, path, handler, require_jwt=false, jwt_secret=None, algorithms=None, read_json_body=true, dependencies=None, jwt_issuer=None, jwt_audience=None, jwt_leeway=None, jwt_cookie=None, body_schema_json=None)
)]
#[allow(clippy::too_many_arguments)]
fn add_route(
Expand All @@ -158,6 +182,7 @@ impl App {
jwt_audience: Option<String>,
jwt_leeway: Option<u64>,
jwt_cookie: Option<String>,
body_schema_json: Option<String>,
) -> PyResult<()> {
let st = self.state.lock().map_err(lock_err)?;
if st.frozen {
Expand Down Expand Up @@ -226,9 +251,18 @@ impl App {
handler_param_names,
handler_varkw,
});
let request_schema: Option<serde_json::Value> = match body_schema_json
.as_deref()
.map(str::trim)
{
None | Some("") => None,
Some(s) => Some(serde_json::from_str(s).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!("invalid body_schema JSON: {e}"))
})?),
};
{
let mut oa = st.openapi.lock().map_err(lock_err)?;
App::openapi_add_path(&mut oa, &method, &path, &op_id);
App::openapi_add_path(&mut oa, &method, &path, &op_id, request_schema);
}
{
let mut m = state::map_method_router(&st, &method).ok_or_else(|| {
Expand Down
23 changes: 23 additions & 0 deletions tests/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json

import httpx
import pytest
from oxyroute import App


Expand Down Expand Up @@ -67,3 +68,25 @@ async def _run() -> None:

asyncio.run(_run())
assert "/z" in json.loads(app.openapi_json())["paths"]


def test_openapi_post_request_body_from_pydantic() -> None:
pydantic = pytest.importorskip("pydantic")

class ItemIn(pydantic.BaseModel):
title: str
count: int = 0

app = App()

@app.post("/items", body_model=ItemIn)
def create_item(json: dict) -> str:
return "ok"

doc = json.loads(app.openapi_json())
rb = doc["paths"]["/items"]["post"]["requestBody"]
assert rb["required"] is True
ct = rb["content"]["application/json"]["schema"]
assert ct["type"] == "object"
assert "title" in ct.get("properties", {})
assert "count" in ct.get("properties", {})
Loading
Loading