Skip to content

Commit 7ba6779

Browse files
feat(server): DevX fixes — sse_response helper, end-of-generate hint, --regenerate
Three frictions surfaced while building the OpenAI Responses and Anthropic Messages examples, all fixed here. 1. SSE construction was verbose: every user had to import axum::Sse + KeepAlive, futures-core, futures-util, plus Box::pin the stream. Now the generator emits `sse_response(stream)` alongside the `ServerEventStream` type alias — wraps any `Stream<Item = Result<Event, Infallible>>` and returns the exact payload the OkStream variant takes. Both examples updated to use it; the streaming branch is now two lines. 2. After `generate` the user had no signpost to the implementation step. The CLI now prints a paste-ready impl skeleton naming the concrete trait, method, body type, and router function picked from the first resolved operation. SSE ops also get a one-line hint about `sse_response`. 3. `server add --regenerate` was declared in the planning doc but never wired. Now it re-execs the binary in `generate` mode using the same compiled artifact — fixes the "add then forget to regenerate" footgun. Verified: 318 lib/integration tests green, clippy -D warnings clean, both example crates' `cargo test` (unary + SSE branches) still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e38d6e4 commit 7ba6779

4 files changed

Lines changed: 124 additions & 19 deletions

File tree

examples/server-anthropic-messages/src/main.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,11 @@
2727
2828
pub mod gen;
2929

30-
use axum::response::sse::{Event, KeepAlive, Sse};
30+
use axum::response::sse::Event;
3131
use futures_util::stream;
3232
use gen::CreateMessageParams;
33-
use gen::server::{MessagesPostResponse, ServerApi, ServerEventStream, server_api_router};
33+
use gen::server::{MessagesPostResponse, ServerApi, server_api_router, sse_response};
3434
use std::convert::Infallible;
35-
use std::time::Duration;
3635

3736
#[derive(Clone)]
3837
struct AppState;
@@ -99,10 +98,7 @@ fn messages_streaming() -> MessagesPostResponse {
9998
sse_event("content_block_stop", r#"{"type":"content_block_stop","index":0}"#),
10099
sse_event("message_stop", r#"{"type":"message_stop"}"#),
101100
]);
102-
let pinned: ServerEventStream = Box::pin(events);
103-
MessagesPostResponse::OkStream(
104-
Sse::new(pinned).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))),
105-
)
101+
MessagesPostResponse::OkStream(sse_response(events))
106102
}
107103

108104
fn sse_event(name: &str, data: &str) -> Result<Event, Infallible> {

examples/server-openai-responses/src/main.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,11 @@
1919
2020
pub mod gen;
2121

22-
use axum::response::sse::{Event, KeepAlive, Sse};
22+
use axum::response::sse::Event;
2323
use futures_util::stream;
2424
use gen::CreateResponse;
25-
use gen::server::{CreateResponseResponse, ResponsesApi, ServerEventStream, responses_api_router};
25+
use gen::server::{CreateResponseResponse, ResponsesApi, responses_api_router, sse_response};
2626
use std::convert::Infallible;
27-
use std::time::Duration;
2827

2928
#[derive(Clone)]
3029
struct AppState;
@@ -76,10 +75,10 @@ fn create_response_streaming() -> CreateResponseResponse {
7675
sse_event("response.output_text.delta", r#"{"delta":"world"}"#),
7776
sse_event("response.completed", r#"{"id":"resp_demo"}"#),
7877
]);
79-
let pinned: ServerEventStream = Box::pin(events);
80-
CreateResponseResponse::OkStream(
81-
Sse::new(pinned).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))),
82-
)
78+
// `sse_response` is the generated helper — wraps any
79+
// `Stream<Item = Result<Event, Infallible>>` so the user
80+
// doesn't have to import axum::Sse or `Box::pin` it manually.
81+
CreateResponseResponse::OkStream(sse_response(events))
8382
}
8483

8584
fn sse_event(name: &str, data: &str) -> Result<Event, Infallible> {

src/bin/openapi-to-rust.rs

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ enum ServerCommands {
8686
/// Print the proposed change without writing.
8787
#[arg(long)]
8888
dry_run: bool,
89+
/// After updating the TOML, immediately run `generate` to
90+
/// emit code for the new selectors.
91+
#[arg(long)]
92+
regenerate: bool,
8993
},
9094
/// Remove a selector entry from `[server].operations`.
9195
Remove {
@@ -277,6 +281,7 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
277281
server_files.len(),
278282
out.display()
279283
);
284+
print_server_hint(&analysis, server_section);
280285
}
281286
}
282287

@@ -321,7 +326,8 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
321326
config,
322327
all_tag,
323328
dry_run,
324-
} => run_server_add(selector, spec, config, all_tag, dry_run),
329+
regenerate,
330+
} => run_server_add(selector, spec, config, all_tag, dry_run, regenerate),
325331
ServerCommands::Remove {
326332
selector,
327333
config,
@@ -395,6 +401,7 @@ fn run_server_add(
395401
config: PathBuf,
396402
all_tag: Option<String>,
397403
dry_run: bool,
404+
regenerate: bool,
398405
) -> Result<(), Box<dyn std::error::Error>> {
399406
let spec_path = resolve_spec_path(spec, &config)?;
400407
let analysis = load_analysis(&spec_path)?;
@@ -455,12 +462,31 @@ fn run_server_add(
455462
}
456463
}
457464
if !dry_run && !added.is_empty() {
465+
let next_step = if regenerate {
466+
"Regenerating now..."
467+
} else {
468+
"Run `openapi-to-rust generate` to emit code."
469+
};
458470
println!(
459-
"\n✓ Added {} entr{} to {}. Run `openapi-to-rust generate` to emit code.",
471+
"\n✓ Added {} entr{} to {}. {next_step}",
460472
added.len(),
461473
if added.len() == 1 { "y" } else { "ies" },
462474
config.display(),
463475
);
476+
if regenerate {
477+
// Re-exec ourselves in `generate` mode against the same
478+
// config. We do this via the binary path (current_exe) so
479+
// we pick up the same compiled artefact the user is using.
480+
let exe = std::env::current_exe()?;
481+
let status = std::process::Command::new(exe)
482+
.arg("generate")
483+
.arg("--config")
484+
.arg(&config)
485+
.status()?;
486+
if !status.success() {
487+
return Err(format!("regenerate failed with status {status}").into());
488+
}
489+
}
464490
}
465491
Ok(())
466492
}
@@ -512,6 +538,74 @@ fn print_add_summary(
512538
Ok(())
513539
}
514540

541+
/// Surface a paste-ready impl skeleton at the end of `generate`.
542+
/// Reads the picked operations from the analysis to name the trait,
543+
/// method, and body type concretely. Goes to stderr so it doesn't
544+
/// pollute machine-readable stdout consumers.
545+
fn print_server_hint(
546+
analysis: &openapi_to_rust::SchemaAnalysis,
547+
server: &openapi_to_rust::config::ServerSection,
548+
) {
549+
use heck::{ToPascalCase, ToSnakeCase};
550+
551+
// Pick the first resolved op to ground the skeleton in concrete
552+
// names. Showing one is enough — users extrapolate to siblings.
553+
let first_op_id = server.operations.first().and_then(|raw| {
554+
Selector::parse(raw).ok().and_then(|sel| match sel {
555+
Selector::OperationId(id) => Some(id),
556+
Selector::MethodPath { method, path } => analysis
557+
.operations
558+
.values()
559+
.find(|op| op.method == method && op.path == path)
560+
.map(|op| op.operation_id.clone()),
561+
Selector::Tag(t) => analysis
562+
.operations
563+
.values()
564+
.find(|op| op.tags.iter().any(|tag| tag == &t))
565+
.map(|op| op.operation_id.clone()),
566+
})
567+
});
568+
569+
let Some(first_op_id) = first_op_id else {
570+
return;
571+
};
572+
let Some(op) = analysis.operations.get(&first_op_id) else {
573+
return;
574+
};
575+
let method = op.operation_id.to_snake_case();
576+
let response_ty = format!("{}Response", op.operation_id.to_pascal_case());
577+
let body_param = match &op.request_body {
578+
Some(rb) => match rb.schema_name() {
579+
Some(name) => format!(", body: {name}"),
580+
None => String::new(),
581+
},
582+
None => String::new(),
583+
};
584+
let tag = op.tags.first().cloned().unwrap_or_else(|| "Server".into());
585+
let trait_name = format!("{}Api", tag.to_pascal_case());
586+
let router_fn = format!("{}_router", trait_name.to_snake_case());
587+
588+
eprintln!();
589+
eprintln!("📝 Next step — implement the trait:");
590+
eprintln!();
591+
eprintln!(" #[derive(Clone)]");
592+
eprintln!(" pub struct AppState {{ /* state goes here */ }}");
593+
eprintln!();
594+
eprintln!(" #[axum::async_trait]");
595+
eprintln!(" impl {trait_name} for AppState {{");
596+
eprintln!(" async fn {method}(&self{body_param}) -> {response_ty} {{");
597+
eprintln!(" todo!()");
598+
eprintln!(" }}");
599+
eprintln!(" }}");
600+
eprintln!();
601+
eprintln!(" // In main():");
602+
eprintln!(" let app = {router_fn}(AppState {{ /* … */ }});");
603+
eprintln!();
604+
if op.supports_streaming {
605+
eprintln!(" For streaming, return `{response_ty}::OkStream(sse_response(your_stream))`.");
606+
}
607+
}
608+
515609
fn run_server_remove(
516610
selector: String,
517611
config: PathBuf,

src/server/codegen.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,9 +348,8 @@ impl<'a> ServerCodegen<'a> {
348348
// need it.
349349
let stream_alias = if any_streaming {
350350
quote! {
351-
/// Stream payload carried by `*Stream` variants. Build with
352-
/// `Box::pin(your_stream)` where each yielded item is a
353-
/// pre-constructed `axum::response::sse::Event`.
351+
/// Stream payload carried by `*Stream` variants. Each
352+
/// yielded item is a pre-built `axum::response::sse::Event`.
354353
pub type ServerEventStream = ::std::pin::Pin<
355354
Box<
356355
dyn ::futures_core::Stream<
@@ -362,6 +361,23 @@ impl<'a> ServerCodegen<'a> {
362361
+ 'static,
363362
>,
364363
>;
364+
365+
/// Wrap any `Stream<Item = Result<Event, Infallible>>` in
366+
/// a `Sse<ServerEventStream>` ready to drop into the
367+
/// `OkStream` variant. Replaces the
368+
/// `Sse::new(Box::pin(...))` dance.
369+
pub fn sse_response<S>(stream: S) -> ::axum::response::sse::Sse<ServerEventStream>
370+
where
371+
S: ::futures_core::Stream<
372+
Item = ::std::result::Result<
373+
::axum::response::sse::Event,
374+
::std::convert::Infallible,
375+
>,
376+
> + ::std::marker::Send
377+
+ 'static,
378+
{
379+
::axum::response::sse::Sse::new(Box::pin(stream))
380+
}
365381
}
366382
} else {
367383
quote! {}

0 commit comments

Comments
 (0)