From 88b2a01af8248ceddb1eb484672d312a120a1f5c Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Sun, 16 Aug 2026 10:35:18 -0400 Subject: [PATCH] Type the blob-write response hash as fabro_types::BlobHash Promote BlobHash to a named OpenAPI schema with the ^[0-9a-f]{64}$ pattern, reference it from WriteBlobResponse.hash and the blobHash path parameter, and map it to fabro_types::BlobHash via with_replacement. The server now serializes the domain type directly and the client gets a parsed BlobHash by construction, removing the to_string/parse adapter pair across the wire boundary. Adds the JSON-parity test required for new replacements. Co-Authored-By: Claude Fable 5 --- docs/public/api-reference/fabro-api.yaml | 13 +++-- .../src/server/handler/artifacts.rs | 5 +- lib/foundation/fabro-api/build.rs | 1 + lib/foundation/fabro-api/src/lib.rs | 2 +- .../fabro-api/tests/blob_hash_round_trip.rs | 50 +++++++++++++++++++ lib/foundation/fabro-client/src/client.rs | 8 +-- .../src/models/write-blob-response.ts | 2 +- 7 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 lib/foundation/fabro-api/tests/blob_hash_round_trip.rs diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 6038b9c34..0352d7f62 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -5980,8 +5980,7 @@ components: required: true description: Content-addressed blob hash. schema: - type: string - pattern: '^[0-9a-f]{64}$' + $ref: "#/components/schemas/BlobHash" example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ArtifactFilename: @@ -10283,6 +10282,12 @@ components: description: Assigned event sequence number. example: 42 + BlobHash: + description: Content-addressed SHA-256 hash of a stored blob. + type: string + pattern: "^[0-9a-f]{64}$" + example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + WriteBlobResponse: description: Content-addressed hash of a stored blob. type: object @@ -10290,9 +10295,7 @@ components: - hash properties: hash: - type: string - description: Content-addressed hash of the stored blob. - example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + $ref: "#/components/schemas/BlobHash" CommandTermination: description: Terminal state for a command execution. diff --git a/lib/apps/fabro-server/src/server/handler/artifacts.rs b/lib/apps/fabro-server/src/server/handler/artifacts.rs index c203bdec0..f0f2b6064 100644 --- a/lib/apps/fabro-server/src/server/handler/artifacts.rs +++ b/lib/apps/fabro-server/src/server/handler/artifacts.rs @@ -105,10 +105,7 @@ async fn write_run_blob( } match state.stores.runs.open_run(&id).await { Ok(run_store) => match run_store.write_blob(&body).await { - Ok(blob_hash) => Json(WriteBlobResponse { - hash: blob_hash.to_string(), - }) - .into_response(), + Ok(blob_hash) => Json(WriteBlobResponse { hash: blob_hash }).into_response(), Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() } diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index ea8ece1eb..0fb3cccd0 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -725,6 +725,7 @@ fn main() { ("WorkflowVersion", "fabro_types::WorkflowVersion", &[]), ("WorkflowPath", "fabro_types::WorkflowPath", &[]), ("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]), + ("BlobHash", "fabro_types::BlobHash", &[]), ("CostSource", "fabro_model::CostSource", &[]), ]; for (name, path, impls) in replacements { diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index b40087831..c53e682c7 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -42,7 +42,7 @@ pub mod types { pub use fabro_types::{ ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary, - AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, + AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash, CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind, diff --git a/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs b/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs new file mode 100644 index 000000000..397d4d00e --- /dev/null +++ b/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs @@ -0,0 +1,50 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::{BlobHash as ApiBlobHash, WriteBlobResponse}; +use fabro_types::BlobHash; +use serde_json::json; + +const BLOB_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + +#[test] +fn blob_hash_schema_reuses_domain_type() { + assert_same_type::(); +} + +#[test] +fn write_blob_response_round_trips_exact_wire_shape() { + let value = json!({ "hash": BLOB_HASH }); + + let response: WriteBlobResponse = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(&response).unwrap(), value); +} + +#[test] +fn blob_hash_emits_the_documented_lowercase_pattern() { + // Serialization must match the OpenAPI schema pattern `^[0-9a-f]{64}$`. + let hash: ApiBlobHash = serde_json::from_value(json!(BLOB_HASH)).unwrap(); + let emitted = serde_json::to_value(hash).unwrap(); + assert_eq!(emitted, json!(BLOB_HASH)); + + let text = emitted.as_str().unwrap(); + assert_eq!(text.len(), 64); + assert!( + text.bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + ); +} + +#[test] +fn blob_hash_rejects_non_hex_values() { + assert!(serde_json::from_value::(json!("not-a-blob-hash")).is_err()); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} must be the domain type {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/foundation/fabro-client/src/client.rs b/lib/foundation/fabro-client/src/client.rs index 8e1c3a1b2..9547dddce 100644 --- a/lib/foundation/fabro-client/src/client.rs +++ b/lib/foundation/fabro-client/src/client.rs @@ -1839,11 +1839,7 @@ impl Client { .await }) .await?; - response - .into_inner() - .hash - .parse() - .context("write_run_blob returned invalid blob hash") + Ok(response.into_inner().hash) } pub async fn read_run_blob( @@ -1856,7 +1852,7 @@ impl Client { .client .read_run_blob() .id(run_id.to_string()) - .blob_hash(blob_hash.to_string()) + .blob_hash(*blob_hash) .send() .await; match response { diff --git a/lib/packages/fabro-api-client/src/models/write-blob-response.ts b/lib/packages/fabro-api-client/src/models/write-blob-response.ts index 7e461b9e7..7058f86ab 100644 --- a/lib/packages/fabro-api-client/src/models/write-blob-response.ts +++ b/lib/packages/fabro-api-client/src/models/write-blob-response.ts @@ -19,7 +19,7 @@ */ export interface WriteBlobResponse { /** - * Content-addressed hash of the stored blob. + * Content-addressed SHA-256 hash of a stored blob. */ 'hash': string; }