refactor(api): reuse domain status types in fabro-api

Replace the generated status family with fabro-types replacements and add
parity tests that lock type identity and JSON shape to the OpenAPI spec.
This commit is contained in:
Bryan Helmkamp 2026-04-21 09:10:53 -04:00
parent ebf8f499af
commit eb7e65b6b4
No known key found for this signature in database
5 changed files with 228 additions and 2 deletions

1
Cargo.lock generated
View file

@ -1544,6 +1544,7 @@ name = "fabro-api"
version = "0.208.0-nightly.1"
dependencies = [
"chrono",
"fabro-types",
"openapiv3",
"prettyplease",
"progenitor",

View file

@ -15,6 +15,7 @@ wildcard_imports = "warn"
[dependencies]
chrono = { workspace = true, features = ["serde"] }
fabro-types = { path = "../fabro-types" }
progenitor-client = "0.13"
regress = "0.10"
reqwest.workspace = true

View file

@ -6,7 +6,7 @@
use std::path::{Path, PathBuf};
use std::{env, fs};
use progenitor::{GenerationSettings, Generator, InterfaceStyle};
use progenitor::{GenerationSettings, Generator, InterfaceStyle, TypeImpl};
/// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable:
/// true`.
@ -160,6 +160,31 @@ fn main() {
let mut settings = GenerationSettings::default();
settings.with_interface(InterfaceStyle::Builder);
settings.with_replacement(
"RunStatus",
"fabro_types::status::RunStatus",
[TypeImpl::FromStr, TypeImpl::Display].into_iter(),
);
settings.with_replacement(
"StatusReason",
"fabro_types::status::StatusReason",
std::iter::empty::<TypeImpl>(),
);
settings.with_replacement(
"BlockedReason",
"fabro_types::status::BlockedReason",
std::iter::empty::<TypeImpl>(),
);
settings.with_replacement(
"RunControlAction",
"fabro_types::status::RunControlAction",
std::iter::empty::<TypeImpl>(),
);
settings.with_replacement(
"RunStatusRecord",
"fabro_types::status::RunStatusRecord",
std::iter::empty::<TypeImpl>(),
);
let mut generator = Generator::new(&settings);
let tokens = generator

View file

@ -13,4 +13,11 @@
mod generated {
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
}
pub use generated::{Client as ApiClient, types};
pub mod types {
pub use fabro_types::status::{
BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason,
};
pub use crate::generated::types::*;
}
pub use generated::Client as ApiClient;

View file

@ -0,0 +1,192 @@
use std::any::{TypeId, type_name};
use chrono::{TimeZone, Utc};
use fabro_api::types::{
BlockedReason as ApiBlockedReason, RunControlAction as ApiRunControlAction,
RunStatus as ApiRunStatus, RunStatusRecord as ApiRunStatusRecord,
StatusReason as ApiStatusReason,
};
use fabro_types::status::{
BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason,
};
use serde::Serialize;
use serde_json::{Value, json};
#[test]
fn status_family_reuses_domain_types() {
assert_same_type::<ApiRunStatus, RunStatus>();
assert_same_type::<ApiStatusReason, StatusReason>();
assert_same_type::<ApiBlockedReason, BlockedReason>();
assert_same_type::<ApiRunControlAction, RunControlAction>();
assert_same_type::<ApiRunStatusRecord, RunStatusRecord>();
}
#[test]
fn status_family_json_tokens_match_openapi() {
for (api_status, workflow_status, expected) in [
(ApiRunStatus::Submitted, RunStatus::Submitted, "submitted"),
(ApiRunStatus::Queued, RunStatus::Queued, "queued"),
(ApiRunStatus::Starting, RunStatus::Starting, "starting"),
(ApiRunStatus::Running, RunStatus::Running, "running"),
(ApiRunStatus::Blocked, RunStatus::Blocked, "blocked"),
(ApiRunStatus::Paused, RunStatus::Paused, "paused"),
(ApiRunStatus::Removing, RunStatus::Removing, "removing"),
(ApiRunStatus::Succeeded, RunStatus::Succeeded, "succeeded"),
(ApiRunStatus::Failed, RunStatus::Failed, "failed"),
(ApiRunStatus::Dead, RunStatus::Dead, "dead"),
(ApiRunStatus::Archived, RunStatus::Archived, "archived"),
] {
assert_string_json(api_status, expected);
assert_string_json(workflow_status, expected);
}
for (api_reason, workflow_reason, expected) in [
(
ApiStatusReason::Completed,
StatusReason::Completed,
"completed",
),
(
ApiStatusReason::PartialSuccess,
StatusReason::PartialSuccess,
"partial_success",
),
(
ApiStatusReason::WorkflowError,
StatusReason::WorkflowError,
"workflow_error",
),
(
ApiStatusReason::Cancelled,
StatusReason::Cancelled,
"cancelled",
),
(
ApiStatusReason::Terminated,
StatusReason::Terminated,
"terminated",
),
(
ApiStatusReason::TransientInfra,
StatusReason::TransientInfra,
"transient_infra",
),
(
ApiStatusReason::BudgetExhausted,
StatusReason::BudgetExhausted,
"budget_exhausted",
),
(
ApiStatusReason::LaunchFailed,
StatusReason::LaunchFailed,
"launch_failed",
),
(
ApiStatusReason::BootstrapFailed,
StatusReason::BootstrapFailed,
"bootstrap_failed",
),
(
ApiStatusReason::SandboxInitFailed,
StatusReason::SandboxInitFailed,
"sandbox_init_failed",
),
(
ApiStatusReason::SandboxInitializing,
StatusReason::SandboxInitializing,
"sandbox_initializing",
),
] {
assert_string_json(api_reason, expected);
assert_string_json(workflow_reason, expected);
}
assert_string_json(ApiBlockedReason::HumanInputRequired, "human_input_required");
assert_string_json(BlockedReason::HumanInputRequired, "human_input_required");
for (api_action, workflow_action, expected) in [
(
ApiRunControlAction::Cancel,
RunControlAction::Cancel,
"cancel",
),
(ApiRunControlAction::Pause, RunControlAction::Pause, "pause"),
(
ApiRunControlAction::Unpause,
RunControlAction::Unpause,
"unpause",
),
] {
assert_string_json(api_action, expected);
assert_string_json(workflow_action, expected);
}
}
#[test]
fn run_status_record_json_matches_openapi_shape() {
let updated_at = Utc
.with_ymd_and_hms(2026, 1, 2, 3, 4, 5)
.single()
.expect("fixed timestamp should be valid");
let expected = json!({
"status": "failed",
"status_reason": "cancelled",
"blocked_reason": "human_input_required",
"updated_at": "2026-01-02T03:04:05Z"
});
let api_record = ApiRunStatusRecord {
status: ApiRunStatus::Failed,
status_reason: Some(ApiStatusReason::Cancelled),
blocked_reason: Some(ApiBlockedReason::HumanInputRequired),
updated_at,
};
assert_eq!(serde_json::to_value(&api_record).unwrap(), expected);
let api_round_trip: ApiRunStatusRecord = serde_json::from_value(expected.clone()).unwrap();
assert_eq!(api_round_trip.status, ApiRunStatus::Failed);
assert_eq!(
api_round_trip.status_reason,
Some(ApiStatusReason::Cancelled)
);
assert_eq!(
api_round_trip.blocked_reason,
Some(ApiBlockedReason::HumanInputRequired)
);
assert_eq!(api_round_trip.updated_at, updated_at);
let workflow_record = RunStatusRecord {
status: RunStatus::Failed,
status_reason: Some(StatusReason::Cancelled),
blocked_reason: Some(BlockedReason::HumanInputRequired),
updated_at,
};
assert_eq!(serde_json::to_value(&workflow_record).unwrap(), expected);
let workflow_round_trip: RunStatusRecord = serde_json::from_value(expected).unwrap();
assert_eq!(workflow_round_trip.status, RunStatus::Failed);
assert_eq!(
workflow_round_trip.status_reason,
Some(StatusReason::Cancelled)
);
assert_eq!(
workflow_round_trip.blocked_reason,
Some(BlockedReason::HumanInputRequired)
);
assert_eq!(workflow_round_trip.updated_at, updated_at);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}
fn assert_string_json<T: Serialize>(value: T, expected: &str) {
assert_eq!(
serde_json::to_value(value).unwrap(),
Value::String(expected.into())
);
}