From 50ba3ab2d5780389da18f017e258af33d760c78f Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:01:53 +0900 Subject: [PATCH] feat(workspace): generate repository API contracts from OpenAPI --- crates/server-api/Cargo.toml | 2 +- .../generate_repository_openapi_types.rs | 59 ++ crates/server-api/src/lib.rs | 23 +- .../src/repository_openapi_typescript.rs | 583 ++++++++++++++++++ web/workspace/deno.json | 6 +- web/workspace/src/lib/generated/README.md | 27 +- .../src/lib/generated/legacy-server-api.ts | 25 +- .../src/lib/generated/repository-api.ts | 34 + web/workspace/src/lib/workspace/api/http.ts | 4 +- .../src/lib/workspace/api/repositories.ts | 102 +++ .../lib/workspace/api/workspace-catalog.ts | 16 +- .../src/lib/workspace/api/workspace-model.ts | 226 +++++-- .../src/routes/w/[workspaceId]/+layout.ts | 21 +- .../repositories/[repositoryKey]/+page.ts | 27 +- .../settings/repositories/+page.svelte | 49 +- .../settings/repositories/+page.ts | 9 +- .../[workspaceId]/tickets/[ticketId]/+page.ts | 9 +- web/workspace/test/repositories/ui.test.ts | 40 +- web/workspace/tests/repository-api.test.ts | 136 ++++ web/workspace/tests/workspace-model.test.ts | 84 ++- 20 files changed, 1276 insertions(+), 206 deletions(-) create mode 100644 crates/server-api/examples/generate_repository_openapi_types.rs create mode 100644 crates/server-api/src/repository_openapi_typescript.rs create mode 100644 web/workspace/src/lib/generated/repository-api.ts create mode 100644 web/workspace/src/lib/workspace/api/repositories.ts create mode 100644 web/workspace/tests/repository-api.test.ts diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index 73e0889c2..d371d6db5 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -15,13 +15,13 @@ protocol.workspace = true runtime-api.workspace = true schemars.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true sha2.workspace = true ts-rs = { version = "12.0.1", optional = true } webauthn-rs-proto = "0.5.5" workdir.workspace = true [dev-dependencies] -serde_json.workspace = true tokio.workspace = true [[example]] diff --git a/crates/server-api/examples/generate_repository_openapi_types.rs b/crates/server-api/examples/generate_repository_openapi_types.rs new file mode 100644 index 000000000..e7b9cfe21 --- /dev/null +++ b/crates/server-api/examples/generate_repository_openapi_types.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, OUTPUT_PATH, generate_repository_typescript, +}; + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mut check = false; + for argument in std::env::args().skip(1) { + match argument.as_str() { + "--check" => check = true, + "--help" | "-h" => { + println!( + "Generate the bounded Repository API TypeScript artifact.\n\n\ + Usage: cargo run -q -p server-api --example generate_repository_openapi_types -- [--check]" + ); + return Ok(()); + } + other => return Err(format!("unknown argument `{other}`")), + } + } + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let input_path = root.join(INPUT_PATH); + let output_path = root.join(OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_repository_typescript(&input) + .map_err(|error| format!("failed to generate Repository API types: {error}"))?; + + if check { + let checked_in = fs::read_to_string(&output_path) + .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?; + if checked_in != generated { + return Err(format!( + "{} is stale; regenerate it without --check", + output_path.display() + )); + } + println!("{} is current", output_path.display()); + return Ok(()); + } + + let temporary_path = output_path.with_extension("ts.tmp"); + fs::write(&temporary_path, generated) + .map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?; + fs::rename(&temporary_path, &output_path) + .map_err(|error| format!("failed to replace {}: {error}", output_path.display()))?; + println!("generated {}", output_path.display()); + Ok(()) +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index bc1e943a6..ebe0d0ccf 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,6 +8,7 @@ use api_macros::api; pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, HttpMethod}; +pub mod repository_openapi_typescript; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -3469,16 +3470,7 @@ pub fn legacy_catalog_typescript() -> String { WorkspaceProfileSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config), WorkspaceProfileSourceProvenance::decl(&config), - RepositorySourceKind::decl(&config), - RepositorySource::decl(&config), - RepositoryObservedStatus::decl(&config), - RepositoryDiagnostic::decl(&config), - GitRemoteSummary::decl(&config), - GitRepositorySummary::decl(&config), - RepositorySummary::decl(&config), GitCommitSummary::decl(&config), - RepositoryListResponse::decl(&config), - RepositoryDetailResponse::decl(&config), RepositorySshConnectionProbeRequest::decl(&config), RepositorySshHostKeyCandidate::decl(&config), RepositorySshConnectionTrustState::decl(&config), @@ -3519,7 +3511,7 @@ pub fn legacy_catalog_typescript() -> String { .map(|declaration| format!("export {declaration}")); format!( - "// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.\n// Do not edit this file directly.\n\n{}\n", + "// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.\n// Do not edit this file directly.\n\nimport type {{ RepositoryObservedStatus, RepositorySource }} from \"./repository-api\";\n\n{}\n", declarations.join("\n\n") ) } @@ -4521,9 +4513,14 @@ mod tests { ); assert!(output.contains("export type WorkspaceResponse =")); assert!(output.contains("permissions: WorkspacePermissionSummary")); - assert!(output.contains("export type RepositoryListResponse =")); - assert!(output.contains("items: Array")); - assert!(output.contains("observed_at?: string | null")); + assert!(output.contains( + "import type { RepositoryObservedStatus, RepositorySource } from \"./repository-api\";" + )); + assert!(!output.contains("export type RepositoryListResponse =")); + assert!(!output.contains("export type RepositoryDetailResponse =")); + assert!(!output.contains("export type RepositorySummary =")); + assert!(!output.contains("export type RepositorySource =")); + assert!(!output.contains("export type RepositoryObservedStatus =")); assert!(output.contains("export type WorkspaceMetadataSettingsResponse =")); assert!(output.contains("export type WorkspaceMetadataMutationResponse =")); assert!(output.contains("export type ProfileSettingsResponse =")); diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs new file mode 100644 index 000000000..a10a7491b --- /dev/null +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -0,0 +1,583 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript"; +pub const GENERATOR_VERSION: &str = "1"; +pub const GENERATOR_OPTIONS: &str = + "repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output"; +pub const INPUT_PATH: &str = "openapi/server-api.json"; +pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts"; + +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; +const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0; +const SCHEMA_REF_PREFIX: &str = "#/components/schemas/"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationError(String); + +impl GenerationError { + fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl std::fmt::Display for GenerationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for GenerationError {} + +pub fn generate_repository_typescript(input: &str) -> Result { + let document: Value = serde_json::from_str(input) + .map_err(|error| GenerationError::invalid(format!("invalid OpenAPI JSON: {error}")))?; + let root = object(&document, "OpenAPI document")?; + if root.get("openapi").and_then(Value::as_str) != Some("3.1.0") { + return Err(GenerationError::invalid( + "Repository TypeScript generation requires OpenAPI 3.1.0", + )); + } + + let info = object_field(root, "info", "OpenAPI document")?; + let contract_version = required_string(info, "version", "OpenAPI info")?; + let contract_digest = required_string(info, "x-yoi-source-digest", "OpenAPI info")?; + validate_contract_digest(contract_digest)?; + + let schemas = object_field( + object_field(root, "components", "OpenAPI document")?, + "schemas", + "OpenAPI components", + )?; + let paths = object_field(root, "paths", "OpenAPI document")?; + let roots = validate_repository_operations(paths)?; + let closure = schema_closure(schemas, &roots)?; + + let mut output = format!( + "// @generated by {GENERATOR_NAME}@{GENERATOR_VERSION}. Do not edit.\n\ +// Input: {INPUT_PATH}\n\ +// Input contract: {contract_version} ({contract_digest})\n\ +// Options: {GENERATOR_OPTIONS}\n\ +// Output: {OUTPUT_PATH}\n\ +// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check\n\n" + ); + for name in closure { + let schema = schemas + .get(&name) + .ok_or_else(|| GenerationError::invalid(format!("missing schema `{name}`")))?; + let declaration = render_schema(schema, &format!("schema `{name}`"))?; + output.push_str(&format!("export type {name} = {declaration};\n\n")); + } + output.pop(); + Ok(output) +} + +fn validate_contract_digest(digest: &str) -> Result<(), GenerationError> { + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(GenerationError::invalid( + "OpenAPI source digest must use sha256", + )); + }; + if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(GenerationError::invalid( + "OpenAPI source digest must contain 64 hexadecimal digits", + )); + } + Ok(()) +} + +fn validate_repository_operations( + paths: &Map, +) -> Result, GenerationError> { + let mut roots = BTreeSet::new(); + for operation in [ + OperationContract { + path: "/api/repositories", + method: "get", + operation_id: "repository_list_alias", + request: None, + successes: &[("200", "RepositoryListResponse")], + }, + OperationContract { + path: "/api/repositories/{repository_key}", + method: "get", + operation_id: "repository_detail_alias", + request: None, + successes: &[("200", "RepositoryDetailResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories", + method: "get", + operation_id: "repository_list", + request: None, + successes: &[("200", "RepositoryListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories", + method: "post", + operation_id: "repository_create", + request: Some("CreateWorkspaceRepositoryRequest"), + successes: &[ + ("200", "CreateWorkspaceRepositoryResponse"), + ("201", "CreateWorkspaceRepositoryResponse"), + ], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories/{repository_key}", + method: "get", + operation_id: "repository_detail", + request: None, + successes: &[("200", "RepositoryDetailResponse")], + }, + ] { + let path = object_field(paths, operation.path, "OpenAPI paths")?; + let value = object_field(path, operation.method, operation.path)?; + if value.get("operationId").and_then(Value::as_str) != Some(operation.operation_id) { + return Err(GenerationError::invalid(format!( + "{} {} must retain operationId `{}`", + operation.method, operation.path, operation.operation_id + ))); + } + + match operation.request { + Some(expected) => { + let request = object_field(value, "requestBody", operation.operation_id)?; + let schema = json_content_schema(request, operation.operation_id)?; + require_schema_ref(schema, expected, operation.operation_id)?; + roots.insert(expected.to_owned()); + } + None if value.contains_key("requestBody") => { + return Err(GenerationError::invalid(format!( + "{} unexpectedly gained a request body", + operation.operation_id + ))); + } + None => {} + } + + let responses = object_field(value, "responses", operation.operation_id)?; + let successes = operation + .successes + .iter() + .copied() + .collect::>(); + for (status, response) in responses { + let response = object( + response, + &format!("{} response {status}", operation.operation_id), + )?; + let schema = json_content_schema(response, operation.operation_id)?; + let expected = successes + .get(status.as_str()) + .copied() + .unwrap_or("RepositoryApiError"); + require_schema_ref(schema, expected, operation.operation_id)?; + roots.insert(expected.to_owned()); + } + for (status, expected) in successes { + if !responses.contains_key(status) { + return Err(GenerationError::invalid(format!( + "{} is missing success response {status} ({expected})", + operation.operation_id + ))); + } + } + } + Ok(roots) +} + +struct OperationContract { + path: &'static str, + method: &'static str, + operation_id: &'static str, + request: Option<&'static str>, + successes: &'static [(&'static str, &'static str)], +} + +fn json_content_schema<'a>( + value: &'a Map, + context: &str, +) -> Result<&'a Value, GenerationError> { + object_field( + object_field(value, "content", context)?, + "application/json", + context, + )? + .get("schema") + .ok_or_else(|| GenerationError::invalid(format!("{context} is missing its JSON schema"))) +} + +fn require_schema_ref(value: &Value, expected: &str, context: &str) -> Result<(), GenerationError> { + let reference = object(value, context)? + .get("$ref") + .and_then(Value::as_str) + .ok_or_else(|| { + GenerationError::invalid(format!("{context} must use a schema reference")) + })?; + if reference != format!("{SCHEMA_REF_PREFIX}{expected}") { + return Err(GenerationError::invalid(format!( + "{context} must reference `{expected}`, found `{reference}`" + ))); + } + Ok(()) +} + +fn schema_closure( + schemas: &Map, + roots: &BTreeSet, +) -> Result, GenerationError> { + let mut closure = BTreeSet::new(); + let mut pending = roots.iter().cloned().collect::>(); + while let Some(name) = pending.pop() { + if !closure.insert(name.clone()) { + continue; + } + let schema = schemas.get(&name).ok_or_else(|| { + GenerationError::invalid(format!("OpenAPI schema `{name}` is missing")) + })?; + let mut references = BTreeSet::new(); + collect_schema_references(schema, &name, &mut references)?; + pending.extend(references); + } + Ok(closure) +} + +fn collect_schema_references( + value: &Value, + context: &str, + references: &mut BTreeSet, +) -> Result<(), GenerationError> { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref") { + let reference = reference.as_str().ok_or_else(|| { + GenerationError::invalid(format!("{context} contains a non-string $ref")) + })?; + let Some(name) = reference.strip_prefix(SCHEMA_REF_PREFIX) else { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported reference `{reference}`" + ))); + }; + references.insert(name.to_owned()); + } + for value in object.values() { + collect_schema_references(value, context, references)?; + } + } + Value::Array(values) => { + for value in values { + collect_schema_references(value, context, references)?; + } + } + _ => {} + } + Ok(()) +} + +fn render_schema(value: &Value, context: &str) -> Result { + let schema = object(value, context)?; + if let Some(reference) = schema.get("$ref") { + let reference = reference.as_str().ok_or_else(|| { + GenerationError::invalid(format!("{context} contains a non-string $ref")) + })?; + return reference + .strip_prefix(SCHEMA_REF_PREFIX) + .map(str::to_owned) + .ok_or_else(|| { + GenerationError::invalid(format!( + "{context} contains unsupported reference `{reference}`" + )) + }); + } + if let Some(constant) = schema.get("const") { + return render_literal(constant, context); + } + if let Some(enumeration) = schema.get("enum") { + let values = enumeration.as_array().ok_or_else(|| { + GenerationError::invalid(format!("{context} contains a non-array enum")) + })?; + if values.is_empty() { + return Err(GenerationError::invalid(format!( + "{context} contains an empty enum" + ))); + } + return values + .iter() + .map(|value| render_literal(value, context)) + .collect::, _>>() + .map(|values| values.join(" | ")); + } + if let Some(branches) = schema.get("anyOf") { + return render_nullable_union(branches, context); + } + if let Some(branches) = schema.get("oneOf") { + let branches = branches.as_array().ok_or_else(|| { + GenerationError::invalid(format!("{context} contains a non-array oneOf")) + })?; + if branches.is_empty() { + return Err(GenerationError::invalid(format!( + "{context} contains an empty oneOf" + ))); + } + let mut rendered = Vec::with_capacity(branches.len()); + for branch in branches { + let branch_object = object(branch, context)?; + if !branch_object.contains_key("enum") && !branch_object.contains_key("const") { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported ambiguous oneOf semantics" + ))); + } + rendered.push(render_schema(branch, context)?); + } + return Ok(rendered.join(" | ")); + } + + match schema.get("type") { + Some(Value::String(kind)) => render_typed_schema(kind, schema, context), + Some(Value::Array(kinds)) => { + if kinds.len() != 2 || !kinds.iter().any(|kind| kind.as_str() == Some("null")) { + return Err(GenerationError::invalid(format!( + "{context} contains an unsupported type union" + ))); + } + let kind = kinds + .iter() + .find_map(|kind| { + let kind = kind.as_str()?; + (kind != "null").then_some(kind) + }) + .ok_or_else(|| { + GenerationError::invalid(format!("{context} has no non-null type")) + })?; + Ok(format!( + "{} | null", + render_typed_schema(kind, schema, context)? + )) + } + _ => Err(GenerationError::invalid(format!( + "{context} has no supported type" + ))), + } +} + +fn render_nullable_union(value: &Value, context: &str) -> Result { + let branches = value + .as_array() + .ok_or_else(|| GenerationError::invalid(format!("{context} contains a non-array anyOf")))?; + if branches.len() != 2 + || branches + .iter() + .filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null")) + .count() + != 1 + { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported ambiguous anyOf semantics" + ))); + } + let branch = branches + .iter() + .find(|branch| branch.get("type").and_then(Value::as_str) != Some("null")) + .expect("validated one non-null branch"); + Ok(format!("{} | null", render_schema(branch, context)?)) +} + +fn render_typed_schema( + kind: &str, + schema: &Map, + context: &str, +) -> Result { + match kind { + "string" => Ok("string".to_owned()), + "boolean" => Ok("boolean".to_owned()), + "null" => Ok("null".to_owned()), + "integer" => { + let minimum = schema.get("minimum").and_then(Value::as_f64); + let maximum = schema.get("maximum").and_then(Value::as_f64); + if minimum.is_none_or(|minimum| minimum < MIN_SAFE_INTEGER) + || maximum.is_none_or(|maximum| maximum > MAX_SAFE_INTEGER) + { + return Err(GenerationError::invalid(format!( + "{context} integer range exceeds JavaScript safe integers" + ))); + } + Ok("number".to_owned()) + } + "array" => { + let items = schema.get("items").ok_or_else(|| { + GenerationError::invalid(format!("{context} array has no item schema")) + })?; + Ok(format!("Array<{}>", render_schema(items, context)?)) + } + "object" => render_object(schema, context), + other => Err(GenerationError::invalid(format!( + "{context} uses unsupported type `{other}`" + ))), + } +} + +fn render_object(schema: &Map, context: &str) -> Result { + let properties = schema + .get("properties") + .and_then(Value::as_object) + .ok_or_else(|| GenerationError::invalid(format!("{context} object has no properties")))?; + let required = match schema.get("required") { + Some(Value::Array(required)) => required + .iter() + .map(|name| { + name.as_str().map(str::to_owned).ok_or_else(|| { + GenerationError::invalid(format!( + "{context} contains a non-string required property" + )) + }) + }) + .collect::, _>>()?, + None => BTreeSet::new(), + Some(_) => { + return Err(GenerationError::invalid(format!( + "{context} contains a non-array required declaration" + ))); + } + }; + if let Some(missing) = required.iter().find(|name| !properties.contains_key(*name)) { + return Err(GenerationError::invalid(format!( + "{context} requires missing property `{missing}`" + ))); + } + if schema + .get("additionalProperties") + .is_some_and(|value| value != &Value::Bool(false)) + { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported additionalProperties" + ))); + } + + let mut fields = Vec::with_capacity(properties.len()); + for (name, property) in properties { + let optional = if required.contains(name) { "" } else { "?" }; + let name = if is_typescript_identifier(name) { + name.clone() + } else { + serde_json::to_string(name).expect("serializing a property name cannot fail") + }; + fields.push(format!( + "{name}{optional}: {}", + render_schema(property, context)? + )); + } + Ok(format!("{{ {}; }}", fields.join("; "))) +} + +fn render_literal(value: &Value, context: &str) -> Result { + match value { + Value::String(_) | Value::Bool(_) | Value::Null => { + serde_json::to_string(value).map_err(|error| { + GenerationError::invalid(format!("failed to render {context} literal: {error}")) + }) + } + _ => Err(GenerationError::invalid(format!( + "{context} contains an unsupported non-string enum/const literal" + ))), + } +} + +fn is_typescript_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + (first.is_ascii_alphabetic() || matches!(first, b'_' | b'$')) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')) +} + +fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map, GenerationError> { + value + .as_object() + .ok_or_else(|| GenerationError::invalid(format!("{context} must be an object"))) +} + +fn object_field<'a>( + value: &'a Map, + field: &str, + context: &str, +) -> Result<&'a Map, GenerationError> { + value + .get(field) + .ok_or_else(|| GenerationError::invalid(format!("{context} is missing `{field}`"))) + .and_then(|value| object(value, &format!("{context}.{field}"))) +} + +fn required_string<'a>( + value: &'a Map, + field: &str, + context: &str, +) -> Result<&'a str, GenerationError> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| GenerationError::invalid(format!("{context} requires string `{field}`"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OPENAPI: &str = include_str!("../../../openapi/server-api.json"); + const TYPESCRIPT: &str = + include_str!("../../../web/workspace/src/lib/generated/repository-api.ts"); + + #[test] + fn checked_in_repository_typescript_is_current() { + assert_eq!( + generate_repository_typescript(OPENAPI).unwrap(), + TYPESCRIPT, + "regenerate with `cargo run -q -p server-api --example generate_repository_openapi_types`", + ); + } + + #[test] + fn output_records_authority_and_preserves_openapi_nullability() { + let output = generate_repository_typescript(OPENAPI).unwrap(); + assert!(output.contains(&format!( + "@generated by {GENERATOR_NAME}@{GENERATOR_VERSION}" + ))); + assert!(output.contains("Input contract: 0.1.0 (sha256:")); + assert!(output.contains("export type RepositorySourceKind = \"local_path\" | \"file\" | \"ssh\" | \"https\" | \"invalid\"")); + assert!(output.contains("git?: GitRepositorySummary | null")); + assert!(output.contains("default_ref?: string | null")); + assert!(!output.contains("export type string =")); + } + + #[test] + fn generator_rejects_lossy_safe_integer_and_union_changes() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = + serde_json::json!(9_007_199_254_740_992_u64); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("safe integers")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"type": "string"})); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("ambiguous anyOf")); + } + + #[test] + fn generator_is_bounded_to_the_repository_operation_contract() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["UnmigratedDomain"] = serde_json::json!({ + "type": "object", + "properties": {"pretend": {"type": "string"}}, + "required": ["pretend"], + "additionalProperties": false + }); + let output = generate_repository_typescript(&document.to_string()).unwrap(); + assert!(!output.contains("UnmigratedDomain")); + } +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index fb402a999..a30dd80a2 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,10 +6,12 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", - "preview": "deno run -A npm:vite@7.2.7 preview" + "preview": "deno run -A npm:vite@7.2.7 preview", + "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", + "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check" }, "imports": { "$lib/": "./src/lib/", diff --git a/web/workspace/src/lib/generated/README.md b/web/workspace/src/lib/generated/README.md index 912e9f5f8..dc9467dbe 100644 --- a/web/workspace/src/lib/generated/README.md +++ b/web/workspace/src/lib/generated/README.md @@ -1,19 +1,20 @@ -# Generated TypeScript inventory +# Generated frontend contracts -`server-api` is the Rust source authority for the checked-in Server HTTP DTO projections in this directory. -Regenerate them from the repository root with: +Checked-in files in this directory are generated API contracts. Do not edit them by hand. + +## Repository list/detail/create API + +`repository-api.ts` is the only TypeScript declaration authority for the Repository list, detail, and create request/response/error wire types. It is generated from the schema closure of the five Repository operations in the canonical `openapi/server-api.json` artifact. The generated header pins the in-repository generator name/version/options, canonical input path and digest, and output path. + +Regenerate or verify it from the repository root: ```sh -cargo run -q -p server-api --features typescript --example generate_legacy_typescript > web/workspace/src/lib/generated/legacy-server-api.ts -cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts -cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts -cargo run -q -p server-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts -cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts -cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts -cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts -cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts +cargo run -q -p server-api --example generate_repository_openapi_types +cargo run -q -p server-api --example generate_repository_openapi_types -- --check ``` -`legacy-server-api.ts` is the bounded aggregate projection retained during the OpenAPI migration. It is not the final OpenAPI-derived Frontend artifact and must not become a second contract authority. The other files are narrower projections generated from the same Rust source. +The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, unsupported enum literals, and integer ranges that cannot be represented safely by JavaScript numbers. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types. -`ticket-api.ts` and `protocol.ts` are owned by the separate `ticket` and `protocol` authorities respectively. +## Other generated contracts + +The remaining files retain their generator command in their header. `legacy-server-api.ts` is transitional: new bounded domain slices should use a focused contract artifact instead of adding another API surface to it. diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index 2648edfd8..7c4b0b9df 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -1,6 +1,8 @@ // This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`. // Do not edit this file directly. +import type { RepositoryObservedStatus, RepositorySource } from "./repository-api"; + export type WorkspaceSummary = { workspace_id: string, owner_account_id: string, display_name: string, state: string, created_at: string, updated_at: string, }; export type WorkspaceCatalogListResponse = Array; @@ -63,31 +65,8 @@ export type WorkspaceProfileSourceSummary = { profile_source_id: string, display export type WorkspaceProfileSourceProvenance = "project_profile_source_tree"; -export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid"; - -export type RepositorySource = { kind: RepositorySourceKind, -/** - * Canonical source representation. This is an absolute local path for - * `local_path`, and a normalized URI/remote specification otherwise. - */ -uri: string, }; - -export type RepositoryObservedStatus = "unverified" | "ready" | "invalid"; - -export type RepositoryDiagnostic = { severity: string, code: string, message: string, }; - -export type GitRemoteSummary = { name: string, fetch_url: string, }; - -export type GitRepositorySummary = { status: string, head: string | null, branch: string | null, dirty: boolean, remotes: Array, }; - -export type RepositorySummary = { repository_key: string, kind: string, provider: string, source: RepositorySource, source_revision: number, source_fingerprint: string, observed_status: RepositoryObservedStatus, observed_at?: string | null, default_selector?: string | null, record_authority: string, git?: GitRepositorySummary | null, diagnostics?: Array | null, }; - export type GitCommitSummary = { hash: string, short_hash: string, summary: string, author_name: string, author_email: string, author_date: string, parents: Array, refs: Array, }; -export type RepositoryListResponse = { workspace_id: string, items: Array, source: string, diagnostics: Array, }; - -export type RepositoryDetailResponse = { workspace_id: string, item: RepositorySummary, source: string, }; - export type RepositorySshConnectionProbeRequest = { runtime_id: string, }; export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts new file mode 100644 index 000000000..4e6a1fe75 --- /dev/null +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -0,0 +1,34 @@ +// @generated by yoi-repository-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef) +// Options: repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output +// Output: web/workspace/src/lib/generated/repository-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check + +export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; }; + +export type CreateWorkspaceRepositoryResponse = { replayed: boolean; repository_key: string; workspace_id: string; }; + +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type GitRemoteSummary = { fetch_url: string; name: string; }; + +export type GitRepositorySummary = { branch?: string | null; dirty: boolean; head?: string | null; remotes: Array; status: string; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type RepositoryDetailResponse = { item: RepositorySummary; source: string; workspace_id: string; }; + +export type RepositoryDiagnostic = { code: string; message: string; severity: string; }; + +export type RepositoryListResponse = { diagnostics: Array; items: Array; source: string; workspace_id: string; }; + +export type RepositoryObservedStatus = "unverified" | "ready" | "invalid"; + +export type RepositorySource = { kind: RepositorySourceKind; uri: string; }; + +export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid"; + +export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; source_revision: number; }; diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 2f207fac1..15e240d34 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -16,7 +16,7 @@ export type ApiResult = { export type { SkillCatalogResponse, SkillDetailResponse }; -type JsonLoadPolicy = { +export type JsonLoadPolicy = { diagnosticLabel: string; maxResponseBytes: number; }; @@ -133,7 +133,7 @@ export async function loadJson( } } -async function readBoundedJson( +export async function readBoundedJson( response: Response, maxBytes: number, ): Promise { diff --git a/web/workspace/src/lib/workspace/api/repositories.ts b/web/workspace/src/lib/workspace/api/repositories.ts new file mode 100644 index 000000000..3a505dce8 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/repositories.ts @@ -0,0 +1,102 @@ +import type { + CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, + RepositoryDetailResponse, + RepositoryListResponse, +} from "$lib/generated/repository-api.ts"; +import { + type ApiResult, + loadJson, + readBoundedJson, + workspaceApiPath, +} from "$lib/workspace/api/http.ts"; +import { + parseCreateWorkspaceRepositoryResponse, + parseRepositoryApiError, + parseRepositoryDetailResponse, + parseRepositoryListResponse, +} from "$lib/workspace/api/workspace-model.ts"; + +export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +const REPOSITORY_API_LOAD_POLICY = { + diagnosticLabel: "Repository API", + maxResponseBytes: REPOSITORY_API_MAX_RESPONSE_BYTES, +} as const; + +export function workspaceRepositoryListPath(workspaceId: string): string { + return workspaceApiPath(workspaceId, "/repositories"); +} + +export function workspaceRepositoryDetailPath( + workspaceId: string, + repositoryKey: string, +): string { + return workspaceApiPath( + workspaceId, + `/repositories/${encodeURIComponent(repositoryKey)}`, + ); +} + +export async function loadWorkspaceRepositoryList( + fetchFn: typeof fetch, + workspaceId: string, +): Promise> { + return loadJson( + fetchFn, + workspaceRepositoryListPath(workspaceId), + undefined, + parseRepositoryListResponse, + REPOSITORY_API_LOAD_POLICY, + ); +} + +export async function loadWorkspaceRepositoryDetail( + fetchFn: typeof fetch, + workspaceId: string, + repositoryKey: string, +): Promise> { + return loadJson( + fetchFn, + workspaceRepositoryDetailPath(workspaceId, repositoryKey), + undefined, + parseRepositoryDetailResponse, + REPOSITORY_API_LOAD_POLICY, + ); +} + +export async function createWorkspaceRepository( + fetchFn: typeof fetch, + workspaceId: string, + request: CreateWorkspaceRepositoryRequest, +): Promise> { + const path = workspaceRepositoryListPath(workspaceId); + try { + const response = await fetchFn(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + const payload = await readBoundedJson( + response, + REPOSITORY_API_MAX_RESPONSE_BYTES, + ); + if (!response.ok) { + try { + const error = parseRepositoryApiError(payload); + return { data: null, error: error.message }; + } catch { + return { + data: null, + error: `Repository API request failed with HTTP ${response.status}`, + }; + } + } + return { + data: parseCreateWorkspaceRepositoryResponse(payload), + error: null, + }; + } catch { + return { data: null, error: "Repository API response is invalid" }; + } +} diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 320195eb1..4287c9a31 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -1,11 +1,11 @@ import { - parseRepositoryListResponse, parseWorkspaceCatalogResponse, parseWorkspaceCreateResponse, type RepositorySummary, type WorkspaceCreateResponse, type WorkspaceSummary, } from "$lib/workspace/api/workspace-model"; +import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts"; export type WorkspaceCatalogRecord = WorkspaceSummary; export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { @@ -48,12 +48,14 @@ export async function listWorkspaceRepositories( fetcher: Fetch, workspaceId: string, ): Promise { - return parseRepositoryListResponse( - await fetchJson( - fetcher, - `/api/w/${encodeURIComponent(workspaceId)}/repositories`, - ), - ).items; + const result = await loadWorkspaceRepositoryList(fetcher, workspaceId); + if (result.data === null) { + throw new WorkspaceCatalogError( + null, + result.error ?? "Repository API request failed", + ); + } + return result.data.items; } export async function loadWorkspaceCatalog( diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index f4808df05..1a4ce55f1 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -1,19 +1,10 @@ import type { ApiResult } from "$lib/workspace/api/http"; import type { - Diagnostic, GitCommitSummary, - GitRemoteSummary, - GitRepositorySummary, - RepositoryDetailResponse, - RepositoryDiagnostic, - RepositoryListResponse, RepositoryLogResponse, - RepositorySource, - RepositorySourceKind, RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState, RepositorySshHostKeyCandidate, - RepositorySummary, WorkspaceAuthConfig, WorkspaceCatalogListResponse, WorkspaceCreateResponse, @@ -30,17 +21,25 @@ import type { WorkspaceResponse, WorkspaceSummary, } from "$lib/generated/legacy-server-api.ts"; +import type { + CreateWorkspaceRepositoryResponse, + Diagnostic, + GitRemoteSummary, + GitRepositorySummary, + RepositoryApiError, + RepositoryDetailResponse, + RepositoryDiagnostic, + RepositoryListResponse, + RepositorySource, + RepositorySourceKind, + RepositorySummary, +} from "$lib/generated/repository-api.ts"; export type { GitCommitSummary, - GitRemoteSummary, - GitRepositorySummary, - RepositoryDetailResponse, - RepositoryListResponse, RepositoryLogResponse, RepositorySshConnectionProbeResponse, RepositorySshHostKeyCandidate, - RepositorySummary, WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceDeletionOperationResponse, @@ -49,6 +48,18 @@ export type { WorkspaceResponse, WorkspaceSummary, } from "$lib/generated/legacy-server-api.ts"; +export type { + CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, + GitRemoteSummary, + GitRepositorySummary, + RepositoryApiError, + RepositoryDetailResponse, + RepositoryListResponse, + RepositorySource, + RepositorySourceKind, + RepositorySummary, +} from "$lib/generated/repository-api.ts"; type JsonObject = Record; @@ -62,6 +73,11 @@ const SOURCE_KINDS = new Set([ const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]); const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); +export const REPOSITORY_API_LIMITS = { + maxStringCodeUnits: 65_536, + maxCollectionEntries: 4_096, +} as const; + function object(value: unknown, path: string): JsonObject { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`${path} must be an object`); @@ -95,6 +111,26 @@ function nullableString(value: unknown, path: string): string | null { return value === null ? null : string(value, path); } +function repositoryString(value: unknown, path: string): string { + const parsed = string(value, path); + if (parsed.length > REPOSITORY_API_LIMITS.maxStringCodeUnits) { + throw new Error(`${path} exceeds the Repository API string limit`); + } + return parsed; +} + +function nullableRepositoryString(value: unknown, path: string): string | null { + return value === null ? null : repositoryString(value, path); +} + +function repositoryArray(value: unknown, path: string): unknown[] { + const parsed = array(value, path); + if (parsed.length > REPOSITORY_API_LIMITS.maxCollectionEntries) { + throw new Error(`${path} exceeds the Repository API collection limit`); + } + return parsed; +} + function optionalNullableString( value: unknown, path: string, @@ -102,6 +138,15 @@ function optionalNullableString( return value === undefined ? undefined : nullableString(value, path); } +function optionalNullableRepositoryString( + value: unknown, + path: string, +): string | null | undefined { + return value === undefined + ? undefined + : nullableRepositoryString(value, path); +} + function exactKeys( value: JsonObject, keys: readonly string[], @@ -128,6 +173,20 @@ function diagnostic(value: unknown, path: string): Diagnostic { }; } +function repositoryApiDiagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], path); + const severity = repositoryString(item.severity, `${path}.severity`); + if (!DIAGNOSTIC_SEVERITIES.has(severity)) { + throw new Error(`${path}.severity is invalid`); + } + return { + code: repositoryString(item.code, `${path}.code`), + severity: severity as Diagnostic["severity"], + message: repositoryString(item.message, `${path}.message`), + }; +} + function repositoryDiagnostic( value: unknown, path: string, @@ -135,22 +194,22 @@ function repositoryDiagnostic( const item = object(value, path); exactKeys(item, ["severity", "code", "message"], path); return { - severity: string(item.severity, `${path}.severity`), - code: string(item.code, `${path}.code`), - message: string(item.message, `${path}.message`), + severity: repositoryString(item.severity, `${path}.severity`), + code: repositoryString(item.code, `${path}.code`), + message: repositoryString(item.message, `${path}.message`), }; } function repositorySource(value: unknown, path: string): RepositorySource { const source = object(value, path); exactKeys(source, ["kind", "uri"], path); - const kind = string(source.kind, `${path}.kind`); + const kind = repositoryString(source.kind, `${path}.kind`); if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) { throw new Error(`${path}.kind is invalid`); } return { kind: kind as RepositorySourceKind, - uri: string(source.uri, `${path}.uri`), + uri: repositoryString(source.uri, `${path}.uri`), }; } @@ -158,8 +217,8 @@ function gitRemote(value: unknown, path: string): GitRemoteSummary { const remote = object(value, path); exactKeys(remote, ["name", "fetch_url"], path); return { - name: string(remote.name, `${path}.name`), - fetch_url: string(remote.fetch_url, `${path}.fetch_url`), + name: repositoryString(remote.name, `${path}.name`), + fetch_url: repositoryString(remote.fetch_url, `${path}.fetch_url`), }; } @@ -167,13 +226,14 @@ function gitSummary(value: unknown, path: string): GitRepositorySummary { const git = object(value, path); exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path); return { - status: string(git.status, `${path}.status`), - head: nullableString(git.head, `${path}.head`), - branch: nullableString(git.branch, `${path}.branch`), + status: repositoryString(git.status, `${path}.status`), + head: nullableRepositoryString(git.head, `${path}.head`), + branch: nullableRepositoryString(git.branch, `${path}.branch`), dirty: boolean(git.dirty, `${path}.dirty`), - remotes: array(git.remotes, `${path}.remotes`).map((item, index) => - gitRemote(item, `${path}.remotes[${index}]`) - ), + remotes: repositoryArray(git.remotes, `${path}.remotes`).map(( + item, + index, + ) => gitRemote(item, `${path}.remotes[${index}]`)), }; } @@ -197,7 +257,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { ], path, ); - const observedStatus = string( + const observedStatus = repositoryString( item.observed_status, `${path}.observed_status`, ); @@ -207,29 +267,36 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { const diagnostics = item.diagnostics === undefined || item.diagnostics === null ? item.diagnostics - : array(item.diagnostics, `${path}.diagnostics`).map((entry, index) => - repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`) - ); + : repositoryArray(item.diagnostics, `${path}.diagnostics`).map(( + entry, + index, + ) => repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)); return { - repository_key: string(item.repository_key, `${path}.repository_key`), - kind: string(item.kind, `${path}.kind`), - provider: string(item.provider, `${path}.provider`), + repository_key: repositoryString( + item.repository_key, + `${path}.repository_key`, + ), + kind: repositoryString(item.kind, `${path}.kind`), + provider: repositoryString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), source_revision: integer(item.source_revision, `${path}.source_revision`), - source_fingerprint: string( + source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, ), observed_status: observedStatus as RepositorySummary["observed_status"], - observed_at: optionalNullableString( + observed_at: optionalNullableRepositoryString( item.observed_at, `${path}.observed_at`, ), - default_selector: optionalNullableString( + default_selector: optionalNullableRepositoryString( item.default_selector, `${path}.default_selector`, ), - record_authority: string(item.record_authority, `${path}.record_authority`), + record_authority: repositoryString( + item.record_authority, + `${path}.record_authority`, + ), git: item.git === undefined || item.git === null ? item.git : gitSummary(item.git, `${path}.git`), @@ -287,7 +354,7 @@ function workspaceRepositoryRecord( ], path, ); - const observedStatus = string( + const observedStatus = repositoryString( item.observed_status, `${path}.observed_status`, ); @@ -302,7 +369,7 @@ function workspaceRepositoryRecord( source: repositorySource(item.source, `${path}.source`), default_ref: nullableString(item.default_ref, `${path}.default_ref`), source_revision: integer(item.source_revision, `${path}.source_revision`), - source_fingerprint: string( + source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, ), @@ -533,21 +600,28 @@ export function parseRepositoryListResponse( "repository list response", ); return { - workspace_id: string( + workspace_id: repositoryString( response.workspace_id, "repository list response.workspace_id", ), - items: array(response.items, "repository list response.items").map(( - item, - index, - ) => repositorySummary(item, `repository list response.items[${index}]`)), - source: string(response.source, "repository list response.source"), - diagnostics: array( + items: repositoryArray(response.items, "repository list response.items") + .map(( + item, + index, + ) => repositorySummary(item, `repository list response.items[${index}]`)), + source: repositoryString( + response.source, + "repository list response.source", + ), + diagnostics: repositoryArray( response.diagnostics, "repository list response.diagnostics", ).map( (item, index) => - diagnostic(item, `repository list response.diagnostics[${index}]`), + repositoryApiDiagnostic( + item, + `repository list response.diagnostics[${index}]`, + ), ), }; } @@ -578,12 +652,64 @@ export function parseRepositoryDetailResponse( "repository detail response", ); return { - workspace_id: string( + workspace_id: repositoryString( response.workspace_id, "repository detail response.workspace_id", ), item: repositorySummary(response.item, "repository detail response.item"), - source: string(response.source, "repository detail response.source"), + source: repositoryString( + response.source, + "repository detail response.source", + ), + }; +} + +export function parseCreateWorkspaceRepositoryResponse( + value: unknown, +): CreateWorkspaceRepositoryResponse { + const response = object(value, "repository create response"); + exactKeys( + response, + ["replayed", "repository_key", "workspace_id"], + "repository create response", + ); + return { + replayed: boolean(response.replayed, "repository create response.replayed"), + repository_key: repositoryString( + response.repository_key, + "repository create response.repository_key", + ), + workspace_id: repositoryString( + response.workspace_id, + "repository create response.workspace_id", + ), + }; +} + +export function parseRepositoryApiError(value: unknown): RepositoryApiError { + const response = object(value, "repository error response"); + exactKeys( + response, + ["diagnostics", "error", "message"], + "repository error response", + ); + return { + diagnostics: response.diagnostics === undefined + ? undefined + : repositoryArray( + response.diagnostics, + "repository error response.diagnostics", + ).map((item, index) => + repositoryApiDiagnostic( + item, + `repository error response.diagnostics[${index}]`, + ) + ), + error: repositoryString(response.error, "repository error response.error"), + message: repositoryString( + response.message, + "repository error response.message", + ), }; } diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.ts b/web/workspace/src/routes/w/[workspaceId]/+layout.ts index ad0a58ddc..8f83bb8d1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.ts @@ -1,16 +1,14 @@ import { error } from "@sveltejs/kit"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import { - parseRepositoryListResponse, - parseWorkspaceResponse, -} from "$lib/workspace/api/workspace-model"; +import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories"; +import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model"; import type { LayoutLoad } from "./$types"; export const load: LayoutLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const [workspaceResult, repositoryResult] = await Promise.all([ loadJson(fetch, workspaceApiPath(workspaceId, "/workspace")), - loadJson(fetch, workspaceApiPath(workspaceId, "/repositories")), + loadWorkspaceRepositoryList(fetch, workspaceId), ]); let workspace = null; @@ -30,17 +28,8 @@ export const load: LayoutLoad = async ({ fetch, params }) => { }); } - let repositories = null; - let repositoriesError = repositoryResult.error; - if (repositoryResult.data !== null) { - try { - repositories = parseRepositoryListResponse(repositoryResult.data); - } catch (cause) { - repositoriesError = cause instanceof Error - ? cause.message - : "invalid repository list response"; - } - } + const repositories = repositoryResult.data; + const repositoriesError = repositoryResult.error; return { workspace, diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts index d307a6177..46cd01f42 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts @@ -1,22 +1,14 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { loadWorkspaceRepositoryDetail } from "$lib/workspace/api/repositories"; import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; -import { - parseRepositoryDetailResponse, - parseRepositoryLogResponse, -} from "$lib/workspace/api/workspace-model"; +import { parseRepositoryLogResponse } from "$lib/workspace/api/workspace-model"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const repositoryKey = params.repositoryKey; const [repositoryResult, logResult, runtimesResult] = await Promise.all([ - loadJson( - fetch, - workspaceApiPath( - workspaceId, - `/repositories/${encodeURIComponent(repositoryKey)}`, - ), - ), + loadWorkspaceRepositoryDetail(fetch, workspaceId, repositoryKey), loadJson( fetch, workspaceApiPath( @@ -30,17 +22,8 @@ export const load: PageLoad = async ({ fetch, params }) => { ), ]); - let repository = null; - let repositoryError = repositoryResult.error; - if (repositoryResult.data !== null) { - try { - repository = parseRepositoryDetailResponse(repositoryResult.data); - } catch (cause) { - repositoryError = cause instanceof Error - ? cause.message - : "invalid repository detail response"; - } - } + const repository = repositoryResult.data; + const repositoryError = repositoryResult.error; let log = null; let logError = logResult.error; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte index 2ce544c4a..0a317829f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.svelte @@ -1,7 +1,11 @@ diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.ts index 0a04c9260..a57a69bdf 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repositories/+page.ts @@ -1,13 +1,10 @@ -import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import { parseRepositoryListResponse } from "$lib/workspace/api/workspace-model"; +import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { - const repositories = await loadJson( + const repositories = await loadWorkspaceRepositoryList( fetch, - workspaceApiPath(params.workspaceId, "/repositories"), - undefined, - parseRepositoryListResponse, + params.workspaceId, ); return { workspaceId: params.workspaceId, diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts index c3ce05a18..52211172d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts @@ -1,6 +1,6 @@ import { redirect } from "@sveltejs/kit"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model"; +import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories"; import { canonicalResourceReference, resourceKey, @@ -17,10 +17,7 @@ export const load = (async ({ fetch, params }) => { ); const [ticket, repositoriesRaw, orchestrator] = await Promise.all([ loadJson(fetch, ticketPath), - loadJson( - fetch, - workspaceApiPath(params.workspaceId, "/repositories"), - ), + loadWorkspaceRepositoryList(fetch, params.workspaceId), loadJson( fetch, workspaceApiPath(params.workspaceId, "/orchestrator"), @@ -40,7 +37,7 @@ export const load = (async ({ fetch, params }) => { ); } } - const repositories = parseRepositoryListApiResult(repositoriesRaw); + const repositories = repositoriesRaw; return { workspaceId: params.workspaceId, diff --git a/web/workspace/test/repositories/ui.test.ts b/web/workspace/test/repositories/ui.test.ts index dcfd4ffc5..561901cdd 100644 --- a/web/workspace/test/repositories/ui.test.ts +++ b/web/workspace/test/repositories/ui.test.ts @@ -20,29 +20,39 @@ const loadSource = await Deno.readTextFile( ), ); +const apiSource = await Deno.readTextFile( + new URL("../../src/lib/workspace/api/repositories.ts", import.meta.url), +); + test("Repository settings use the scoped list and typed create collection", () => { - for (const token of [ - "workspaceApiPath(params.workspaceId, \"/repositories\")", - "workspaceApiPath(data.workspaceId, '/repositories')", - "method: 'POST'", - "repository_key: repositoryKey", - "default_ref: defaultRef || null", - "await invalidateAll()", - ]) { + const source = `${pageSource}\n${loadSource}\n${apiSource}`; + for ( + const token of [ + "loadWorkspaceRepositoryList", + "createWorkspaceRepository", + "workspaceRepositoryListPath(workspaceId)", + 'method: "POST"', + "repository_key: repositoryKey", + "default_ref: defaultRef || null", + "await invalidateAll()", + ] + ) { assert( - pageSource.includes(token) || loadSource.includes(token), + source.includes(token), `Repository settings should include ${token}`, ); } }); test("Repository Add form keeps access secrets outside registration input", () => { - for (const forbidden of [ - "private_key", - "passphrase", - "credential_id", - "host_trust_id", - ]) { + for ( + const forbidden of [ + "private_key", + "passphrase", + "credential_id", + "host_trust_id", + ] + ) { assert( !pageSource.includes(forbidden), `Repository registration must not accept ${forbidden}`, diff --git a/web/workspace/tests/repository-api.test.ts b/web/workspace/tests/repository-api.test.ts new file mode 100644 index 000000000..59b149567 --- /dev/null +++ b/web/workspace/tests/repository-api.test.ts @@ -0,0 +1,136 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { + createWorkspaceRepository, + loadWorkspaceRepositoryList, + REPOSITORY_API_MAX_RESPONSE_BYTES, +} from "../src/lib/workspace/api/repositories.ts"; + +const repository = { + repository_key: "main", + kind: "git", + provider: "git", + source: { kind: "local_path", uri: "/srv/main" }, + source_revision: 1, + source_fingerprint: "sha256:main", + observed_status: "ready", + record_authority: "workspace-control-plane", +}; + +Deno.test("Repository API loaders parse unknown JSON through the strict generated contract", async () => { + const valid = await loadWorkspaceRepositoryList( + () => + Promise.resolve( + Response.json({ + workspace_id: "workspace-a", + items: [repository], + source: "workspace-control-plane", + diagnostics: [], + }), + ), + "workspace-a", + ); + if (valid.data?.items[0]?.repository_key !== "main") { + throw new Error(`unexpected load result: ${valid.error}`); + } + + const invalid = await loadWorkspaceRepositoryList( + () => + Promise.resolve( + Response.json({ + workspace_id: "workspace-a", + items: [{ ...repository, observed_status: "future_status" }], + source: "workspace-control-plane", + diagnostics: [], + }), + ), + "workspace-a", + ); + if ( + invalid.data !== null || + !invalid.error?.includes("Repository API response is invalid") + ) { + throw new Error("unknown enum values must fail closed in the loader"); + } +}); + +Deno.test("Repository create client validates request, response, and typed API errors", async () => { + const success = await createWorkspaceRepository( + (_input, init) => { + const request: unknown = JSON.parse(String(init?.body)); + if ( + typeof request !== "object" || request === null || + !("repository_key" in request) || request.repository_key !== "main" + ) { + throw new Error("typed create request was not serialized"); + } + return Promise.resolve( + Response.json({ + workspace_id: "workspace-a", + repository_key: "main", + replayed: false, + }), + ); + }, + "workspace-a", + { repository_key: "main", source: "/srv/main", default_ref: null }, + ); + if (success.data?.repository_key !== "main" || success.data.replayed) { + throw new Error(`unexpected create result: ${success.error}`); + } + + const rejected = await createWorkspaceRepository( + () => + Promise.resolve( + Response.json( + { error: "invalid_source", message: "source is invalid" }, + { status: 400 }, + ), + ), + "workspace-a", + { repository_key: "main", source: "bad", default_ref: null }, + ); + if (rejected.data !== null || rejected.error !== "source is invalid") { + throw new Error("typed RepositoryApiError message was not preserved"); + } + + const invalid = await createWorkspaceRepository( + () => + Promise.resolve( + Response.json({ + workspace_id: "workspace-a", + repository_key: "main", + replayed: null, + }), + ), + "workspace-a", + { repository_key: "main", source: "/srv/main", default_ref: null }, + ); + if ( + invalid.data !== null || + invalid.error !== "Repository API response is invalid" + ) { + throw new Error("invalid create success payload must fail closed"); + } +}); + +Deno.test("Repository API clients bound response bodies before parsing JSON", async () => { + const result = await loadWorkspaceRepositoryList( + () => + Promise.resolve( + new Response("{}", { + headers: { + "content-length": String(REPOSITORY_API_MAX_RESPONSE_BYTES + 1), + }, + }), + ), + "workspace-a", + ); + if ( + result.data !== null || !result.error?.includes("exceeds its byte limit") + ) { + throw new Error(`oversized response was not rejected: ${result.error}`); + } +}); diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index cd0a30407..02d380050 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -4,11 +4,14 @@ declare const Deno: { }; import { + parseCreateWorkspaceRepositoryResponse, + parseRepositoryApiError, parseRepositoryListApiResult, parseRepositoryListResponse, parseWorkspaceDeletionOperationResponse, parseWorkspaceDeletionPreflightResponse, parseWorkspaceResponse, + REPOSITORY_API_LIMITS, } from "../src/lib/workspace/api/workspace-model.ts"; function assertThrows(operation: () => unknown, expected: string): void { @@ -71,6 +74,63 @@ Deno.test("stale repository aliases fail closed at the JSON boundary", () => { ); }); +Deno.test("repository create/error responses use the generated strict contract", () => { + const created = parseCreateWorkspaceRepositoryResponse({ + workspace_id: repositoryList.workspace_id, + repository_key: "main", + replayed: false, + }); + if (created.replayed || created.repository_key !== "main") { + throw new Error("create response fields were not preserved"); + } + const error = parseRepositoryApiError({ + error: "invalid_request", + message: "source is invalid", + }); + if (error.error !== "invalid_request") { + throw new Error("error response fields were not preserved"); + } + assertThrows( + () => + parseCreateWorkspaceRepositoryResponse({ + workspace_id: repositoryList.workspace_id, + repository_key: "main", + replayed: null, + }), + ".replayed must be a boolean", + ); + assertThrows( + () => + parseRepositoryApiError({ + error: "invalid_request", + message: "source is invalid", + context: {}, + }), + ".context is not part", + ); +}); + +Deno.test("repository responses retain bounded strings and collections", () => { + const oversizedString = structuredClone(repositoryList); + oversizedString.items[0].provider = "x".repeat( + REPOSITORY_API_LIMITS.maxStringCodeUnits + 1, + ); + assertThrows( + () => parseRepositoryListResponse(oversizedString), + "string limit", + ); + + const oversizedCollection = structuredClone(repositoryList); + oversizedCollection.items = Array.from( + { length: REPOSITORY_API_LIMITS.maxCollectionEntries + 1 }, + () => repositoryList.items[0], + ); + assertThrows( + () => parseRepositoryListResponse(oversizedCollection), + "collection limit", + ); +}); + Deno.test("repository API result converts stale payloads into bounded page errors", () => { const result = parseRepositoryListApiResult({ data: { @@ -227,7 +287,7 @@ Deno.test("Workspace settings exposes owner-gated typed destructive confirmation }); Deno.test("Repository settings consume the validated shared wire shape", async () => { - const [loadSource, pageSource] = await Promise.all([ + const [loadSource, pageSource, apiSource, modelSource] = await Promise.all([ Deno.readTextFile( new URL( "../src/routes/w/[workspaceId]/settings/repositories/+page.ts", @@ -240,21 +300,41 @@ Deno.test("Repository settings consume the validated shared wire shape", async ( import.meta.url, ), ), + Deno.readTextFile( + new URL("../src/lib/workspace/api/repositories.ts", import.meta.url), + ), + Deno.readTextFile( + new URL("../src/lib/workspace/api/workspace-model.ts", import.meta.url), + ), ]); + const targetSources = `${loadSource}\n${pageSource}\n${apiSource}`; for ( const token of [ + "loadWorkspaceRepositoryList", + "createWorkspaceRepository", "parseRepositoryListResponse", + "parseCreateWorkspaceRepositoryResponse", + "parseRepositoryApiError", "repository.repository_key", "repository.observed_status", "sourceLabel(repository.source.kind)", "supportsRepositoryAccess(repository.source.kind)", ] ) { - if (!loadSource.includes(token) && !pageSource.includes(token)) { + if (!targetSources.includes(token) && !modelSource.includes(token)) { throw new Error(`Repository settings should include ${token}`); } } + if ( + pageSource.includes("generated/legacy-server-api") || + pageSource.includes("response.json()") || + pageSource.includes(" as CreateWorkspaceRepository") + ) { + throw new Error( + "Repository create UI must use the generated contract and strict API client", + ); + } for (const kind of ["ssh", "https"]) { if (!pageSource.includes(`kind === '${kind}'`)) { throw new Error(`Repository Access should support ${kind}`);