fabro/lib/crates/fabro-api/tests/openapi_conformance.rs
brynary-fabro[bot] a7faf32e01 Rename [feature_flags] to [features] (#11)
This PR renames the `[feature_flags]` configuration section to
`[features]` and the corresponding `FeatureFlags` type to `Features`
across the entire stack. The change touches the OpenAPI spec (source of
truth), Rust config/API crates, the generated TypeScript API client, the
web application, and the demo config file to ensure consistency.

On the Rust side, the `FeatureFlags` struct becomes `Features` in
`fabro-config`, and all field references (`config.feature_flags` →
`config.features`) are updated in `fabro-api` demo/test code along with
the relevant unit tests. On the TypeScript side, the generated client
reflects the OpenAPI rename (`feature-flags.ts` → `features.ts`,
`FeatureFlags` → `Features`), and manual edits in `fabro-web` update the
`AppConfig` interface, defaults constant (`FEATURES_DEFAULTS`), loader
data destructuring, and template usage throughout `config.server.ts`,
`app-shell.tsx`, and `start.tsx`.

The regeneration also picked up an unrelated new `GitHubConfiguration`
schema that was already present in the OpenAPI spec but hadn't been
generated yet, resulting in the new `git-hub-configuration.ts` file and
its addition to the server configuration type.

### Fabro Details

<details>
<summary>Ran 7 stages in 15m 4s for $3.08</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $2.18 | 0 |
| simplify | 0s | $0.90 | 0 |
| verify | 0s | – | 0 |
| **Total** | **15m 4s** | **$3.08** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
    simplify          [label="Simplify", prompt="@prompts/simplify.md"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify -> verify
    verify -> exit  [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:54:40 -04:00

399 lines
13 KiB
Rust

//! Conformance tests: spec ↔ router ↔ Rust struct consistency.
use std::collections::BTreeSet;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_api::jwt_auth::AuthMode;
use fabro_api::server::{build_router, create_app_state};
use fabro_api::server_config::*;
use fabro_workflows::cli::run_config::*;
use fabro_workflows::daytona_sandbox::*;
use fabro_workflows::handler::exit::ExitHandler;
use fabro_workflows::handler::start::StartHandler;
use fabro_workflows::handler::HandlerRegistry;
use fabro_workflows::hook::*;
use fabro_workflows::interviewer::Interviewer;
use tower::ServiceExt;
fn test_registry(_interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry
}
async fn test_db() -> sqlx::SqlitePool {
let pool = fabro_db::connect_memory().await.unwrap();
fabro_db::initialize_db(&pool).await.unwrap();
pool
}
fn load_spec() -> openapiv3::OpenAPI {
let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/fabro-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("failed to read spec");
serde_yaml::from_str(&text).expect("failed to parse spec")
}
fn resolve_path(path: &str) -> String {
path.replace("{id}", "test-id")
.replace("{qid}", "test-qid")
.replace("{stageId}", "test-stage")
.replace("{name}", "test-name")
.replace("{slug}", "test-slug")
}
fn methods_for_path_item(item: &openapiv3::PathItem) -> Vec<Method> {
let mut methods = Vec::new();
if item.get.is_some() {
methods.push(Method::GET);
}
if item.post.is_some() {
methods.push(Method::POST);
}
if item.put.is_some() {
methods.push(Method::PUT);
}
if item.delete.is_some() {
methods.push(Method::DELETE);
}
if item.patch.is_some() {
methods.push(Method::PATCH);
}
methods
}
#[tokio::test]
async fn all_spec_routes_are_routable() {
let spec = load_spec();
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
let mut checked = 0;
for (path, item) in &spec.paths.paths {
let path_item = match item {
openapiv3::ReferenceOr::Item(item) => item,
openapiv3::ReferenceOr::Reference { .. } => continue,
};
let uri = resolve_path(path);
for method in methods_for_path_item(path_item) {
let mut builder = Request::builder().method(&method).uri(&uri);
let body = if method == Method::POST {
builder = builder.header("content-type", "application/json");
Body::from("{}")
} else {
Body::empty()
};
let req = builder.body(body).unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_ne!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"Route {method} {path} returned 405 — not registered in the router"
);
checked += 1;
}
}
assert!(checked > 0, "No routes were checked — is the spec empty?");
}
// ── ServerConfig ↔ OpenAPI schema drift detection ──────────────────────
/// Load the spec as serde_json::Value for schema introspection.
fn load_spec_json() -> serde_json::Value {
let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("docs/api-reference/fabro-api.yaml");
let text = std::fs::read_to_string(&spec_path).expect("read spec");
serde_yaml::from_str(&text).expect("parse spec")
}
/// Follow a `$ref` pointer, or return the value unchanged.
fn resolve_ref<'a>(
value: &'a serde_json::Value,
root: &'a serde_json::Value,
) -> &'a serde_json::Value {
match value.get("$ref").and_then(|v| v.as_str()) {
Some(ref_str) => {
let mut cur = root;
for seg in ref_str.trim_start_matches("#/").split('/') {
cur = &cur[seg];
}
cur
}
None => value,
}
}
/// Collect property names from an OpenAPI schema object.
fn spec_keys(schema: &serde_json::Value) -> BTreeSet<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
}
/// Recursively compare serialized JSON keys against OpenAPI schema properties.
fn compare_schema(
path: &str,
json: &serde_json::Value,
schema: &serde_json::Value,
root: &serde_json::Value,
errors: &mut Vec<String>,
) {
let obj = match json.as_object() {
Some(o) => o,
None => return,
};
// Skip pure-map schemas (additionalProperties without properties).
if schema.get("additionalProperties").is_some() && schema.get("properties").is_none() {
return;
}
let json_keys: BTreeSet<String> = obj.keys().cloned().collect();
let schema_keys = spec_keys(schema);
for key in json_keys.difference(&schema_keys) {
errors.push(format!(
"{path}.{key}: in Rust but missing from OpenAPI spec"
));
}
for key in schema_keys.difference(&json_keys) {
errors.push(format!(
"{path}.{key}: in OpenAPI spec but missing from Rust"
));
}
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(p) => p,
None => return,
};
for key in json_keys.intersection(&schema_keys) {
let json_val = &obj[key];
let prop_schema = resolve_ref(&properties[key], root);
// Skip maps and union types.
if prop_schema.get("additionalProperties").is_some() || prop_schema.get("oneOf").is_some() {
continue;
}
match json_val {
serde_json::Value::Object(_) => {
compare_schema(
&format!("{path}.{key}"),
json_val,
prop_schema,
root,
errors,
);
}
serde_json::Value::Array(arr) => {
// Union keys across all array elements.
let union: BTreeSet<String> = arr
.iter()
.filter_map(|e| e.as_object())
.flat_map(|o| o.keys().cloned())
.collect();
if union.is_empty() {
continue;
}
let items = match prop_schema.get("items") {
Some(i) => resolve_ref(i, root),
None => continue,
};
let synthetic = serde_json::Value::Object(
union
.into_iter()
.map(|k| (k, serde_json::Value::Null))
.collect(),
);
compare_schema(&format!("{path}.{key}[]"), &synthetic, items, root, errors);
}
_ => {}
}
}
}
/// Build a ServerConfig with every Option set to Some so all keys appear
/// in the serialized JSON.
fn fully_populated_server_config() -> ServerConfig {
ServerConfig {
data_dir: Some("/data".into()),
max_concurrent_runs: Some(10),
web: WebConfig {
url: "https://example.com".into(),
auth: AuthConfig {
provider: AuthProvider::Github,
allowed_usernames: vec!["user".into()],
},
},
api: ApiConfig {
base_url: "https://api.example.com".into(),
authentication_strategies: vec![ApiAuthStrategy::Jwt],
tls: Some(TlsConfig {
cert: "c".into(),
key: "k".into(),
ca: "ca".into(),
}),
},
git: GitConfig {
provider: GitProvider::Github,
app_id: Some("123".into()),
client_id: Some("456".into()),
slug: Some("fabro".into()),
author: GitAuthorConfig {
name: Some("bot".into()),
email: Some("bot@x".into()),
},
webhooks: Some(WebhookConfig {
strategy: WebhookStrategy::TailscaleFunnel,
}),
},
features: Features {
session_sandboxes: true,
},
log: LogConfig {
level: Some("debug".into()),
},
run_defaults: RunDefaults {
work_dir: Some("/work".into()),
llm: Some(LlmConfig {
model: Some("m".into()),
provider: Some("p".into()),
fallbacks: Some(Default::default()),
}),
setup: Some(SetupConfig {
commands: vec!["echo hi".into()],
timeout_ms: Some(5000),
}),
sandbox: Some(SandboxConfig {
provider: Some("daytona".into()),
preserve: Some(true),
devcontainer: None,
local: None,
daytona: Some(DaytonaConfig {
auto_stop_interval: Some(60),
labels: Some(Default::default()),
snapshot: Some(DaytonaSnapshotConfig {
name: "snap".into(),
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(DockerfileSource::Inline("FROM x".into())),
}),
network: Some(DaytonaNetwork::Block),
}),
exe: Some(fabro_exe::ExeConfig { image: None }),
ssh: None,
env: Some(Default::default()),
}),
vars: Some(Default::default()),
checkpoint: CheckpointConfig {
exclude_globs: vec![],
},
pull_request: Some(PullRequestConfig {
enabled: true,
draft: false,
}),
assets: Some(AssetsConfig {
include: vec!["test-results/**".into()],
}),
// One hook per HookType variant so the key union covers all fields.
hooks: vec![
HookDefinition {
name: Some("cmd".into()),
event: HookEvent::RunStart,
command: Some("echo".into()),
hook_type: None,
matcher: Some("*".into()),
blocking: Some(true),
timeout_ms: Some(5000),
sandbox: Some(true),
},
HookDefinition {
name: Some("http".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Http {
url: "http://x".into(),
headers: Some(Default::default()),
allowed_env_vars: vec!["X".into()],
tls: TlsMode::Verify,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
HookDefinition {
name: Some("prompt".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Prompt {
prompt: "hi".into(),
model: Some("m".into()),
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
HookDefinition {
name: Some("agent".into()),
event: HookEvent::RunStart,
command: None,
hook_type: Some(HookType::Agent {
prompt: "hi".into(),
model: Some("m".into()),
max_tool_rounds: Some(5),
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
},
],
mcp_servers: Default::default(),
github: None,
},
}
}
#[test]
fn server_config_keys_match_openapi_spec() {
let config = fully_populated_server_config();
let json = serde_json::to_value(&config).expect("serialize ServerConfig");
let spec = load_spec_json();
let schema = &spec["components"]["schemas"]["ServerConfiguration"];
let mut errors = Vec::new();
compare_schema("ServerConfiguration", &json, schema, &spec, &mut errors);
if !errors.is_empty() {
panic!(
"ServerConfig ↔ OpenAPI schema drift:\n {}",
errors.join("\n ")
);
}
}