1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
//! `autumn openapi export` — emit the app's `OpenAPI` document without booting it.
//!
//! Compiles the target binary (debug profile), runs it with
//! `AUTUMN_DUMP_OPENAPI=1`, and captures the generated `OpenAPI` 3.1 JSON from its
//! stdout — the same no-boot child-process protocol `autumn routes` uses. No
//! HTTP port is bound and no database is touched.
//!
//! The app builds that document through the exact pair the `/openapi.json` route
//! uses, so what this command writes is what the running server serves. That
//! makes the output directly consumable by the standard `OpenAPI` toolchain:
//!
//! ```text
//! autumn openapi export --out openapi.json
//! npx openapi-typescript openapi.json -o src/api.d.ts
//! ```
//!
//! `--check <path>` re-exports and compares against a committed copy, so a
//! contract change that was not reviewed fails CI instead of shipping.
use std::path::Path;
use std::process::Command;
use autumn_web::openapi::{OpaqueSchema, OpenApiSpec, opaque_component_schemas};
use crate::routes::{
CargoFeatures, CargoProfile, clear_other_dump_modes, compile_binary_with,
find_binary_in_profile,
};
/// The env var selecting the app binary's OpenAPI-document dump mode.
pub const DUMP_ENV: &str = "AUTUMN_DUMP_OPENAPI";
/// Options controlling `autumn openapi export`.
pub struct ExportOptions<'a> {
pub package: Option<&'a str>,
/// Binary target name for packages that expose multiple bin targets.
pub bin: Option<&'a str>,
/// Write the document here instead of stdout.
pub out: Option<&'a Path>,
/// Compare against this committed document and fail on drift.
pub check: Option<&'a Path>,
/// Fail when any component schema degraded to the opaque placeholder.
pub strict: bool,
pub features: CargoFeatures,
/// Build and run the release binary instead of the debug one.
///
/// A route or schema behind `#[cfg(not(debug_assertions))]` exists only in
/// the release build, so a debug-built export describes a contract the
/// deployed binary does not serve — and `--check` would pass against it.
/// Mirrors the profile flag the other manifest commands take.
pub release: bool,
}
/// Run `autumn openapi export`.
pub fn run(opts: &ExportOptions<'_>) {
eprintln!("\u{1F342} autumn openapi export\n");
let spec_json = dump_spec(opts);
// Parse for the quality report only — the bytes written out are the child's
// verbatim, so `--check` compares exactly what a previous run committed.
let report = match serde_json::from_str::<OpenApiSpec>(&spec_json) {
Ok(spec) => opaque_component_schemas(&spec),
Err(e) => {
eprintln!("\u{2717} Failed to parse the emitted OpenAPI document: {e}");
std::process::exit(1);
}
};
report_opaque_schemas(&report);
match (opts.check, opts.out) {
(Some(path), _) => run_check(path, &spec_json, &report, opts.strict),
(None, Some(path)) => {
write_spec(path, &spec_json);
eprintln!("\u{2713} OpenAPI spec written \u{2192} {}", path.display());
exit_on_strict(&report, opts.strict);
}
(None, None) => {
print!("{spec_json}");
exit_on_strict(&report, opts.strict);
}
}
}
/// Build the app and read its `OpenAPI` dump, exiting with an actionable message
/// when the binary has no spec to give.
fn dump_spec(opts: &ExportOptions<'_>) -> String {
// Build and locate under the SAME profile: a command that builds then runs
// must agree with itself about which binary it means.
let profile = CargoProfile::from_release(opts.release);
compile_binary_with(opts.package, opts.bin, &opts.features, &profile);
let binary = find_binary_in_profile(opts.package, opts.bin, &profile);
// `AppBuilder::run` checks its no-boot protocol modes in a fixed ORDER, and
// `AUTUMN_BUILD_STATIC` / `AUTUMN_DUMP_ROUTES` are both checked BEFORE the
// OpenAPI one. A caller whose environment already carries one of them — a
// build-oriented CI job is the obvious case — would have the child render
// static assets or print route JSON instead, and this command would then
// fail to parse an OpenAPI document out of it (issue #2370).
let mut command = Command::new(&binary);
command.env(DUMP_ENV, "1");
clear_other_dump_modes(&mut command, DUMP_ENV);
let output = command
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
std::process::exit(1);
});
// stderr is captured rather than inherited so the unavailable marker can be
// scanned for, but everything else the child said is forwarded verbatim —
// a swallowed config or telemetry warning would make a failing export much
// harder to diagnose than a slightly noisier one.
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr
.lines()
.filter(|l| !l.contains(autumn_web::openapi::OPENAPI_UNAVAILABLE_MARKER))
{
eprintln!("{line}");
}
// The app reports "I have no spec" on a marker rather than by failing to
// print JSON, so the two causes get distinct, fixable advice instead of a
// parse error on empty stdout.
if let Some(reason) = unavailable_reason(&stderr) {
eprintln!("\u{2717} No OpenAPI spec to export: {reason}.\n");
// The marker already distinguishes the two causes, so show only the
// remedy that applies rather than making the reader work out which half
// of a combined message is theirs.
if reason == autumn_web::openapi::OPENAPI_UNAVAILABLE_FEATURE {
eprintln!(" Enable it in the app's Cargo.toml:");
eprintln!(" autumn-web = {{ version = \"..\", features = [\"openapi\"] }}");
} else {
eprintln!(" Configure it in main():");
eprintln!(" .openapi(OpenApiConfig::new(\"My API\", \"1.0.0\"))");
}
std::process::exit(1);
}
if !output.status.success() {
eprintln!(
"\u{2717} Binary exited with status {} while dumping the OpenAPI spec",
output.status
);
std::process::exit(output.status.code().unwrap_or(1));
}
String::from_utf8_lossy(&output.stdout).into_owned()
}
/// The reason text following the unavailable marker, if the child emitted it.
pub fn unavailable_reason(stderr: &str) -> Option<&str> {
stderr.lines().find_map(|line| {
line.split_once(autumn_web::openapi::OPENAPI_UNAVAILABLE_MARKER)
.map(|(_, reason)| reason.trim())
})
}
/// Print the opaque-component report to stderr (never stdout, which may be
/// carrying the document itself).
fn report_opaque_schemas(report: &[OpaqueSchema]) {
if report.is_empty() {
return;
}
eprintln!(
"\u{26A0} {} component schema(s) have no field-level type and export as an \
opaque object:",
report.len()
);
for entry in report {
if entry.referenced_by.is_empty() {
eprintln!(" {}", entry.schema);
} else {
eprintln!(
" {} \u{2190} {}",
entry.schema,
entry.referenced_by.join(", ")
);
}
}
eprintln!(
"\n A client generated from this spec sees `unknown` (TypeScript) or \
`serde_json::Value` (Rust)\n for each of them. Add \
`#[derive(OpenApiSchema)]` to the type, or register a hand-written\n \
schema with `OpenApiConfig::register_schema`.\n"
);
}
/// Compare a freshly exported document against a committed one.
fn run_check(path: &Path, spec_json: &str, report: &[OpaqueSchema], strict: bool) {
let committed = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) => {
eprintln!("\u{2717} Cannot read {} for --check: {e}", path.display());
eprintln!(
" Write it first: autumn openapi export --out {}",
path.display()
);
std::process::exit(1);
}
};
// Compare parsed values, not bytes: a committed file that differs only in
// trailing newline or indentation is not a contract change, and failing CI
// over one would train people to ignore this gate.
let (Ok(lhs), Ok(rhs)) = (
serde_json::from_str::<serde_json::Value>(&committed),
serde_json::from_str::<serde_json::Value>(spec_json),
) else {
eprintln!("\u{2717} {} is not valid JSON", path.display());
std::process::exit(1);
};
if lhs == rhs {
eprintln!("\u{2713} {} is up to date", path.display());
exit_on_strict(report, strict);
return;
}
eprintln!(
"\u{2717} {} is out of date \u{2014} the API contract changed.",
path.display()
);
for line in describe_drift(&lhs, &rhs) {
eprintln!(" {line}");
}
eprintln!(
"\n Regenerate and review the diff: autumn openapi export --out {}",
path.display()
);
std::process::exit(1);
}
/// A short, human-readable summary of how two spec documents differ.
///
/// Deliberately operation-level rather than a full JSON diff: "which endpoints
/// appeared, vanished or changed shape" is the question a reviewer is actually
/// asking, and the file diff is one command away for the rest.
pub fn describe_drift(committed: &serde_json::Value, current: &serde_json::Value) -> Vec<String> {
let mut out = Vec::new();
let ops = |doc: &serde_json::Value| -> std::collections::BTreeMap<String, serde_json::Value> {
let mut map = std::collections::BTreeMap::new();
if let Some(paths) = doc.get("paths").and_then(serde_json::Value::as_object) {
for (path, item) in paths {
if let Some(methods) = item.as_object() {
for (method, operation) in methods {
map.insert(
format!("{} {path}", method.to_uppercase()),
operation.clone(),
);
}
}
}
}
map
};
let before = ops(committed);
let after = ops(current);
for key in after.keys().filter(|k| !before.contains_key(*k)) {
out.push(format!("+ {key}"));
}
for key in before.keys().filter(|k| !after.contains_key(*k)) {
out.push(format!("- {key}"));
}
for (key, value) in &before {
if after.get(key).is_some_and(|current| current != value) {
out.push(format!("~ {key}"));
}
}
// Operations can be identical while the schemas they `$ref` changed, so say
// so rather than reporting "out of date" with an empty explanation.
if out.is_empty() {
out.push("component schemas or document metadata changed".to_owned());
}
out
}
fn write_spec(path: &Path, spec_json: &str) {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("\u{2717} Failed to create {}: {e}", parent.display());
std::process::exit(1);
}
if let Err(e) = std::fs::write(path, spec_json) {
eprintln!("\u{2717} Failed to write {}: {e}", path.display());
std::process::exit(1);
}
}
/// Exit non-zero when `--strict` was passed and the spec has opaque components.
fn exit_on_strict(report: &[OpaqueSchema], strict: bool) {
if strict && !report.is_empty() {
eprintln!(
"\u{2717} --strict: {} opaque component schema(s) in the exported spec",
report.len()
);
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── unavailable_reason ────────────────────────────────────────────────
#[test]
fn reads_the_reason_after_the_marker() {
let stderr = format!(
"some unrelated log\n{}this app never called `.openapi(..)`\n",
autumn_web::openapi::OPENAPI_UNAVAILABLE_MARKER
);
assert_eq!(
unavailable_reason(&stderr),
Some("this app never called `.openapi(..)`")
);
}
#[test]
fn no_marker_means_available() {
assert_eq!(unavailable_reason("just a warning\n"), None);
}
// ── describe_drift ────────────────────────────────────────────────────
fn doc(paths: &serde_json::Value) -> serde_json::Value {
json!({ "openapi": "3.1.0", "paths": paths })
}
#[test]
fn reports_added_and_removed_operations() {
let before = doc(&json!({ "/a": { "get": { "operationId": "a" } } }));
let after = doc(&json!({ "/b": { "post": { "operationId": "b" } } }));
let drift = describe_drift(&before, &after);
assert!(drift.contains(&"+ POST /b".to_owned()), "{drift:?}");
assert!(drift.contains(&"- GET /a".to_owned()), "{drift:?}");
}
#[test]
fn reports_a_changed_operation() {
let before = doc(&json!({ "/a": { "get": { "operationId": "a" } } }));
let after = doc(&json!({ "/a": { "get": { "operationId": "a", "deprecated": true } } }));
assert_eq!(describe_drift(&before, &after), vec!["~ GET /a".to_owned()]);
}
#[test]
fn falls_back_to_a_generic_note_when_only_components_moved() {
let before = doc(&json!({ "/a": { "get": { "operationId": "a" } } }));
let after = doc(&json!({ "/a": { "get": { "operationId": "a" } } }));
// Same operations: the caller only reaches this when the documents
// differ elsewhere, and an empty explanation would be useless.
assert_eq!(
describe_drift(&before, &after),
vec!["component schemas or document metadata changed".to_owned()]
);
}
}