Skip to content

Commit 01e9498

Browse files
authored
Ensure four-star CLI can handle all types from OpenAPI spec (#440)
1 parent 6526db4 commit 01e9498

4 files changed

Lines changed: 168 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rfd-cli/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,11 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
2828
toml = { workspace = true }
2929
uuid = { workspace = true, features = ["serde", "v4"] }
3030

31+
[build-dependencies]
32+
serde_json = { workspace = true }
33+
34+
[dev-dependencies]
35+
regex = { workspace = true }
36+
3137
[package.metadata.dist]
3238
targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-darwin"]

rfd-cli/build.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
// Reads `rfd-api-spec.json` and emits `expected_response_types.rs`
6+
// containing a function that returns the schemars schema name for every JSON
7+
// response type the API can produce. The generated source references real
8+
// types from `rfd_sdk::types`, so a renamed/removed type breaks the build
9+
// instead of silently slipping past the dispatch coverage test.
10+
11+
use serde_json::Value;
12+
use std::collections::BTreeSet;
13+
use std::env;
14+
use std::fs;
15+
use std::path::Path;
16+
17+
fn main() {
18+
let spec_path = "../rfd-api-spec.json";
19+
println!("cargo:rerun-if-changed=build.rs");
20+
println!("cargo:rerun-if-changed={spec_path}");
21+
22+
let spec_text =
23+
fs::read_to_string(spec_path).unwrap_or_else(|e| panic!("read {spec_path}: {e}"));
24+
let spec: Value = serde_json::from_str(&spec_text).expect("parse spec JSON");
25+
26+
let entries = collect_entries(&spec);
27+
28+
let mut src = String::from(
29+
"// AUTO-GENERATED by build.rs — do not edit.\n\
30+
pub fn expected_response_schema_names() -> Vec<String> {\n vec![\n",
31+
);
32+
for entry in &entries {
33+
src.push_str(" ");
34+
src.push_str(entry);
35+
src.push_str(",\n");
36+
}
37+
src.push_str(" ]\n}\n");
38+
39+
let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR set by cargo");
40+
let dest = Path::new(&out_dir).join("expected_response_types.rs");
41+
fs::write(&dest, src).expect("write expected_response_types.rs");
42+
}
43+
44+
fn collect_entries(spec: &Value) -> Vec<String> {
45+
let mut entries: BTreeSet<String> = BTreeSet::new();
46+
let Some(paths) = spec.get("paths").and_then(Value::as_object) else {
47+
return Vec::new();
48+
};
49+
for ops in paths.values() {
50+
let Some(ops) = ops.as_object() else { continue };
51+
for op in ops.values() {
52+
let Some(responses) = op.get("responses").and_then(Value::as_object) else {
53+
continue;
54+
};
55+
for (status, resp) in responses {
56+
if !status.starts_with('2') {
57+
continue;
58+
}
59+
let Some(schema) = resp.pointer("/content/application~1json/schema") else {
60+
continue;
61+
};
62+
if let Some(entry) = schema_to_entry(schema) {
63+
entries.insert(entry);
64+
}
65+
}
66+
}
67+
}
68+
entries.into_iter().collect()
69+
}
70+
71+
fn schema_to_entry(schema: &Value) -> Option<String> {
72+
if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
73+
let rust = strip_underscores(&ref_tail(r));
74+
return Some(format!(
75+
"<rfd_sdk::types::{rust} as schemars::JsonSchema>::schema_name()"
76+
));
77+
}
78+
if schema.get("type").and_then(Value::as_str) == Some("array") {
79+
if let Some(r) = schema.pointer("/items/$ref").and_then(Value::as_str) {
80+
let rust = strip_underscores(&ref_tail(r));
81+
return Some(format!(
82+
"<Vec<rfd_sdk::types::{rust}> as schemars::JsonSchema>::schema_name()"
83+
));
84+
}
85+
// Inline arrays without a `$ref` items target (e.g. `Vec<u8>`) have no
86+
// matching `rfd_sdk::types::*` to reference. The dispatch table is not
87+
// expected to cover them, so we skip rather than emit.
88+
}
89+
None
90+
}
91+
92+
fn ref_tail(r: &str) -> String {
93+
r.rsplit('/').next().unwrap_or_default().to_string()
94+
}
95+
96+
/// Translate an OpenAPI component name (`Foo_for_Bar_and_Baz`) to the Rust
97+
/// identifier progenitor emits (`FooForBarAndBaz`).
98+
fn strip_underscores(s: &str) -> String {
99+
let mut out = String::with_capacity(s.len());
100+
let mut upper_next = false;
101+
for ch in s.chars() {
102+
if ch == '_' {
103+
upper_next = true;
104+
} else if upper_next {
105+
out.extend(ch.to_uppercase());
106+
upper_next = false;
107+
} else {
108+
out.push(ch);
109+
}
110+
}
111+
out
112+
}

rfd-cli/tests/dispatch_coverage.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
// Verifies that every JSON response type the API can produce has a matching
6+
// arm in the dispatch table in `src/main.rs`.
7+
//
8+
// The expected set of schema names is generated at build time by `build.rs`,
9+
// which reads `rfd-api-spec.json` and emits Rust source that references real
10+
// types from `rfd_sdk::types`. The compiler verifies each referenced type
11+
// still exists, so SDK rename/removal breaks the build instead of silently
12+
// passing the test.
13+
14+
use regex::Regex;
15+
use std::collections::HashSet;
16+
use std::fs;
17+
use std::path::PathBuf;
18+
19+
include!(concat!(env!("OUT_DIR"), "/expected_response_types.rs"));
20+
21+
#[test]
22+
fn dispatch_table_covers_all_api_response_types() {
23+
let expected: HashSet<String> = expected_response_schema_names().into_iter().collect();
24+
25+
let main_src =
26+
fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"))
27+
.expect("read rfd-cli/src/main.rs");
28+
29+
// Names handled by the dispatch table in main.rs: any string literal
30+
// followed by `=>`. Over-collects on purpose (catches non-dispatch arms
31+
// too) — we only assert `expected ⊆ handled`.
32+
let arm_re = Regex::new(r#""([A-Za-z_][A-Za-z_0-9<>]*)"\s*=>"#).unwrap();
33+
let handled: HashSet<String> = arm_re
34+
.captures_iter(&main_src)
35+
.map(|c| c[1].to_string())
36+
.collect();
37+
38+
let mut missing: Vec<&String> = expected.difference(&handled).collect();
39+
missing.sort();
40+
assert!(
41+
missing.is_empty(),
42+
"API responses with no dispatch arm in rfd-cli/src/main.rs:\n - {}",
43+
missing
44+
.iter()
45+
.map(|s| s.as_str())
46+
.collect::<Vec<_>>()
47+
.join("\n - ")
48+
);
49+
}

0 commit comments

Comments
 (0)