Skip to content

Commit e38d6e4

Browse files
feat(server): trait, response enums, router, SSE, examples (P4 + P5-lite)
Server-side codegen now emits a working axum service for a user-selected subset of the spec. Trait per tag, status-code-typed response enum with IntoResponse, conditional OkStream variant for SSE, per-trait Router factory. Files emitted to output_dir/server/: mod.rs — re-exports api, errors, router api.rs — `pub trait <Tag>Api { async fn <op>(&self, ...) -> <Op>Response; }` errors.rs — `pub enum <Op>Response { Ok(T), BadRequest(E), ..., OkStream(Sse<S>) }` with IntoResponse → (StatusCode, Json) | sse.into_response() router.rs — `pub fn <tag>_api_router<T>(api: T) -> axum::Router` SSE: when an op's supports_streaming is true, the response enum gains an OkStream(Sse<ServerEventStream>) variant. ServerEventStream is a Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>> alias. User builds the stream and returns the variant; IntoResponse delegates to axum's Sse. Two end-to-end examples added: examples/server-openai-responses/ — createResponse, both branches examples/server-anthropic-messages/ — messages_post, both branches Both examples implement the conditional `body.stream` pattern: when stream:true → return OkStream with canned SSE events; otherwise → return Ok with a JSON body. The Anthropic example ships a small sse-overlay.json schema extension because the upstream spec omits text/event-stream on the 200 response (resolves bd:in6). Each example has unit tests verifying both branches construct correctly at runtime. The new gated integration test tests/server_examples_test.rs regenerates code + runs cargo test against both examples (`cargo test --test server_examples_test -- --ignored`). Closes openapi-generator-jih (P4 trait + response enums). Closes openapi-generator-in6 (Anthropic SSE gap). Partial openapi-generator-cdx (P5 router factory shipped; typed query/header extractors and model pruning still pending). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fd49f73 commit e38d6e4

16 files changed

Lines changed: 1186 additions & 3 deletions

File tree

.beads/issues.jsonl

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
target/
2+
Cargo.lock
3+
src/gen/
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "server-anthropic-messages"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[dependencies]
8+
axum = "0.7"
9+
serde = { version = "1", features = ["derive"] }
10+
serde_json = "1"
11+
tokio = { version = "1", features = ["full"] }
12+
futures-core = "0.3"
13+
futures-util = "0.3"
14+
15+
# Pulled in by the generated types — see src/gen/REQUIRED_DEPS.toml
16+
# after running `openapi-to-rust generate`.
17+
base64 = "0.22"
18+
chrono = { version = "0.4", features = ["serde"] }
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# server-anthropic-messages
2+
3+
End-to-end demo of the server codegen: scaffold `POST /v1/messages`
4+
from the Anthropic spec, implement the trait, serve it via axum.
5+
6+
```bash
7+
# From repo root:
8+
cargo run -p openapi-to-rust -- generate \
9+
--config examples/server-anthropic-messages/openapi-to-rust.toml
10+
11+
cargo run --manifest-path examples/server-anthropic-messages/Cargo.toml
12+
13+
# In another shell:
14+
curl -s http://127.0.0.1:3001/v1/messages \
15+
-H 'content-type: application/json' \
16+
-d '{"model":"claude-x","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
17+
```
18+
19+
**Streaming caveat.** Anthropic's published OpenAPI spec doesn't
20+
declare `text/event-stream` on the 200 response, so the generator
21+
can't emit an `OkStream` variant. Tracked as `openapi-generator-in6`
22+
— the planned fix is a schema-extension overlay that adds the
23+
streaming content type, after which this example will gain a
24+
streaming branch with no manual changes.
25+
26+
The integration test in `tests/server_examples_test.rs` guarantees
27+
the example keeps building.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[generator]
2+
spec_path = "../../specs/anthropic.yaml"
3+
output_dir = "src/gen"
4+
module_name = "anthropic"
5+
# Overlay declares text/event-stream on the messages_post 200 response,
6+
# so the generator emits an OkStream variant alongside Ok(Message).
7+
schema_extensions = ["sse-overlay.json"]
8+
9+
[features]
10+
enable_async_client = false
11+
12+
[server]
13+
framework = "axum"
14+
operations = ["messages_post"]
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! Example: host a perfect-replica of Anthropic's `POST /v1/messages`.
2+
//!
3+
//! Exercises both branches of the typed response enum:
4+
//! - `body.stream == Some(true)` → `OkStream(Sse<...>)`
5+
//! - otherwise → `Ok(Message)` (single JSON body)
6+
//!
7+
//! NOTE: Anthropic's published spec declares only `application/json`
8+
//! on the 200 response. This example pulls in a small overlay
9+
//! (`sse-overlay.json`) via the generator's `schema_extensions`
10+
//! mechanism, which declares `text/event-stream` on the 200 so the
11+
//! generated trait gets the `OkStream` variant.
12+
//!
13+
//! Run:
14+
//! 1. `cargo run -p openapi-to-rust -- generate \
15+
//! --config examples/server-anthropic-messages/openapi-to-rust.toml`
16+
//! 2. `cargo run --manifest-path examples/server-anthropic-messages/Cargo.toml`
17+
//! 3. Unary:
18+
//! `curl -s http://127.0.0.1:3001/v1/messages \
19+
//! -H 'content-type: application/json' \
20+
//! -d '{"model":"claude-x","max_tokens":50,
21+
//! "messages":[{"role":"user","content":"hi"}]}'`
22+
//! 4. SSE:
23+
//! `curl -N -s http://127.0.0.1:3001/v1/messages \
24+
//! -H 'content-type: application/json' \
25+
//! -d '{"model":"claude-x","max_tokens":50,"stream":true,
26+
//! "messages":[{"role":"user","content":"hi"}]}'`
27+
28+
pub mod gen;
29+
30+
use axum::response::sse::{Event, KeepAlive, Sse};
31+
use futures_util::stream;
32+
use gen::CreateMessageParams;
33+
use gen::server::{MessagesPostResponse, ServerApi, ServerEventStream, server_api_router};
34+
use std::convert::Infallible;
35+
use std::time::Duration;
36+
37+
#[derive(Clone)]
38+
struct AppState;
39+
40+
#[axum::async_trait]
41+
impl ServerApi for AppState {
42+
async fn messages_post(&self, body: CreateMessageParams) -> MessagesPostResponse {
43+
if body.stream == Some(true) {
44+
messages_streaming()
45+
} else {
46+
messages_unary()
47+
}
48+
}
49+
}
50+
51+
fn messages_unary() -> MessagesPostResponse {
52+
let msg = gen::Message {
53+
container: None,
54+
content: vec![gen::ContentBlock::TextBlock(gen::ResponseTextBlock {
55+
citations: None,
56+
text: "hello (unary)".into(),
57+
})],
58+
id: "msg_demo".into(),
59+
model: gen::Model::Custom("claude-demo".into()),
60+
role: gen::MessageRole::Assistant,
61+
stop_details: None,
62+
stop_reason: None,
63+
stop_sequence: None,
64+
r#type: gen::MessageType::Message,
65+
usage: gen::Usage {
66+
cache_creation: None,
67+
cache_creation_input_tokens: None,
68+
cache_read_input_tokens: None,
69+
inference_geo: None,
70+
input_tokens: 0,
71+
output_tokens: 0,
72+
server_tool_use: None,
73+
service_tier: None,
74+
},
75+
};
76+
MessagesPostResponse::Ok(msg)
77+
}
78+
79+
fn messages_streaming() -> MessagesPostResponse {
80+
// The real Anthropic stream emits message_start →
81+
// content_block_start → content_block_delta* → content_block_stop
82+
// → message_delta → message_stop. The example fires a short
83+
// subset; production code mirrors the full sequence from the
84+
// upstream model.
85+
let events = stream::iter(vec![
86+
sse_event("message_start", r#"{"type":"message_start","message":{"id":"msg_demo"}}"#),
87+
sse_event(
88+
"content_block_start",
89+
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
90+
),
91+
sse_event(
92+
"content_block_delta",
93+
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello "}}"#,
94+
),
95+
sse_event(
96+
"content_block_delta",
97+
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}"#,
98+
),
99+
sse_event("content_block_stop", r#"{"type":"content_block_stop","index":0}"#),
100+
sse_event("message_stop", r#"{"type":"message_stop"}"#),
101+
]);
102+
let pinned: ServerEventStream = Box::pin(events);
103+
MessagesPostResponse::OkStream(
104+
Sse::new(pinned).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))),
105+
)
106+
}
107+
108+
fn sse_event(name: &str, data: &str) -> Result<Event, Infallible> {
109+
Ok(Event::default().event(name).data(data))
110+
}
111+
112+
#[tokio::main]
113+
async fn main() {
114+
let app = server_api_router(AppState);
115+
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await.unwrap();
116+
println!("listening on http://{}", listener.local_addr().unwrap());
117+
axum::serve(listener, app).await.unwrap();
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
124+
fn make_body(stream: Option<bool>) -> CreateMessageParams {
125+
let mut json = serde_json::json!({
126+
"model": "claude-x",
127+
"max_tokens": 50,
128+
"messages": [{"role": "user", "content": "hi"}],
129+
});
130+
if let Some(s) = stream {
131+
json["stream"] = serde_json::Value::Bool(s);
132+
}
133+
serde_json::from_value(json).expect("minimal CreateMessageParams must deserialize")
134+
}
135+
136+
#[tokio::test]
137+
async fn unary_path_returns_ok_variant() {
138+
let r = AppState.messages_post(make_body(None)).await;
139+
assert!(matches!(r, MessagesPostResponse::Ok(_)));
140+
}
141+
142+
#[tokio::test]
143+
async fn stream_path_returns_ok_stream_variant() {
144+
let r = AppState.messages_post(make_body(Some(true))).await;
145+
assert!(matches!(r, MessagesPostResponse::OkStream(_)));
146+
}
147+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"_comment": "Schema extension overlay. Anthropic's published spec declares only application/json on POST /v1/messages 200, but the real API streams via text/event-stream when stream:true is in the request body. This overlay closes that gap so the generator emits an OkStream variant. Tracked as openapi-generator-in6.",
3+
"paths": {
4+
"/v1/messages": {
5+
"post": {
6+
"responses": {
7+
"200": {
8+
"content": {
9+
"text/event-stream": {
10+
"schema": {
11+
"type": "object",
12+
"description": "SSE event payload — opaque to the schema since the streaming protocol carries multiple event types."
13+
}
14+
}
15+
}
16+
}
17+
}
18+
}
19+
}
20+
}
21+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
target/
2+
Cargo.lock
3+
src/gen/
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "server-openai-responses"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[dependencies]
8+
axum = "0.7"
9+
serde = { version = "1", features = ["derive"] }
10+
serde_json = "1"
11+
tokio = { version = "1", features = ["full"] }
12+
futures-core = "0.3"
13+
futures-util = "0.3"
14+
15+
# Crates referenced by the generated types (see src/gen/REQUIRED_DEPS.toml
16+
# after running `openapi-to-rust generate`). For the OpenAI Responses
17+
# spec this is the union currently observed; if you add operations
18+
# that pull more types in, generate will surface the new deps in
19+
# stderr.
20+
bytes = { version = "1", features = ["serde"] }
21+
url = { version = "2", features = ["serde"] }
22+
chrono = { version = "0.4", features = ["serde"] }
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# server-openai-responses
2+
3+
End-to-end demo of the server codegen: scaffold `POST /v1/responses`
4+
from the OpenAI spec, implement the trait, serve it via axum.
5+
6+
```bash
7+
# From repo root:
8+
cargo run -p openapi-to-rust -- generate \
9+
--config examples/server-openai-responses/openapi-to-rust.toml
10+
11+
cargo run --manifest-path examples/server-openai-responses/Cargo.toml
12+
13+
# In another shell:
14+
curl -N -s http://127.0.0.1:3000/responses \
15+
-H 'content-type: application/json' \
16+
-d '{"model":"gpt-x","input":"hi","stream":true}'
17+
```
18+
19+
The stub handler streams four canned SSE events. A real handler swaps
20+
the `stream::iter(...)` for a stream piped out of your model server.
21+
22+
The integration test in `tests/server_examples_test.rs` runs both
23+
steps (generate + build) and is the canonical guarantee the example
24+
keeps working.

0 commit comments

Comments
 (0)