feat(workspace): generate repository API contracts from OpenAPI

This commit is contained in:
2026-09-18 20:01:53 +09:00
parent 8435236862
commit 50ba3ab2d5
20 changed files with 1276 additions and 206 deletions
+1 -1
View File
@@ -15,13 +15,13 @@ protocol.workspace = true
runtime-api.workspace = true runtime-api.workspace = true
schemars.workspace = true schemars.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.5"
workdir.workspace = true workdir.workspace = true
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
[[example]] [[example]]
@@ -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(())
}
+10 -13
View File
@@ -8,6 +8,7 @@ use api_macros::api;
pub use api_macros::axum as server_support; pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support; pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, HttpMethod}; pub use api_macros::{ApiContract, HttpMethod};
pub mod repository_openapi_typescript;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -3469,16 +3470,7 @@ pub fn legacy_catalog_typescript() -> String {
WorkspaceProfileSummary::decl(&config), WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config),
WorkspaceProfileSourceProvenance::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), GitCommitSummary::decl(&config),
RepositoryListResponse::decl(&config),
RepositoryDetailResponse::decl(&config),
RepositorySshConnectionProbeRequest::decl(&config), RepositorySshConnectionProbeRequest::decl(&config),
RepositorySshHostKeyCandidate::decl(&config), RepositorySshHostKeyCandidate::decl(&config),
RepositorySshConnectionTrustState::decl(&config), RepositorySshConnectionTrustState::decl(&config),
@@ -3519,7 +3511,7 @@ pub fn legacy_catalog_typescript() -> String {
.map(|declaration| format!("export {declaration}")); .map(|declaration| format!("export {declaration}"));
format!( 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") declarations.join("\n\n")
) )
} }
@@ -4521,9 +4513,14 @@ mod tests {
); );
assert!(output.contains("export type WorkspaceResponse =")); assert!(output.contains("export type WorkspaceResponse ="));
assert!(output.contains("permissions: WorkspacePermissionSummary")); assert!(output.contains("permissions: WorkspacePermissionSummary"));
assert!(output.contains("export type RepositoryListResponse =")); assert!(output.contains(
assert!(output.contains("items: Array<RepositorySummary>")); "import type { RepositoryObservedStatus, RepositorySource } from \"./repository-api\";"
assert!(output.contains("observed_at?: string | null")); ));
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 WorkspaceMetadataSettingsResponse ="));
assert!(output.contains("export type WorkspaceMetadataMutationResponse =")); assert!(output.contains("export type WorkspaceMetadataMutationResponse ="));
assert!(output.contains("export type ProfileSettingsResponse =")); assert!(output.contains("export type ProfileSettingsResponse ="));
@@ -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<String>) -> 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<String, GenerationError> {
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<String, Value>,
) -> Result<BTreeSet<String>, 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::<BTreeMap<_, _>>();
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<String, Value>,
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<String, Value>,
roots: &BTreeSet<String>,
) -> Result<BTreeSet<String>, GenerationError> {
let mut closure = BTreeSet::new();
let mut pending = roots.iter().cloned().collect::<Vec<_>>();
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<String>,
) -> 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<String, GenerationError> {
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::<Result<Vec<_>, _>>()
.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<String, GenerationError> {
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<String, Value>,
context: &str,
) -> Result<String, GenerationError> {
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<String, Value>, context: &str) -> Result<String, GenerationError> {
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::<Result<BTreeSet<_>, _>>()?,
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<String, GenerationError> {
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<String, Value>, GenerationError> {
value
.as_object()
.ok_or_else(|| GenerationError::invalid(format!("{context} must be an object")))
}
fn object_field<'a>(
value: &'a Map<String, Value>,
field: &str,
context: &str,
) -> Result<&'a Map<String, Value>, 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<String, Value>,
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"));
}
}
+4 -2
View File
@@ -6,10 +6,12 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "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", "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": { "imports": {
"$lib/": "./src/lib/", "$lib/": "./src/lib/",
+14 -13
View File
@@ -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. Checked-in files in this directory are generated API contracts. Do not edit them by hand.
Regenerate them from the repository root with:
## 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 ```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 --example generate_repository_openapi_types
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 --example generate_repository_openapi_types -- --check
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
``` ```
`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.
@@ -1,6 +1,8 @@
// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`. // This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.
// Do not edit this file directly. // 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 WorkspaceSummary = { workspace_id: string, owner_account_id: string, display_name: string, state: string, created_at: string, updated_at: string, };
export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>; export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
@@ -63,31 +65,8 @@ export type WorkspaceProfileSourceSummary = { profile_source_id: string, display
export type WorkspaceProfileSourceProvenance = "project_profile_source_tree"; 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<GitRemoteSummary>, };
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<RepositoryDiagnostic> | null, };
export type GitCommitSummary = { hash: string, short_hash: string, summary: string, author_name: string, author_email: string, author_date: string, parents: Array<string>, refs: Array<string>, }; export type GitCommitSummary = { hash: string, short_hash: string, summary: string, author_name: string, author_email: string, author_date: string, parents: Array<string>, refs: Array<string>, };
export type RepositoryListResponse = { workspace_id: string, items: Array<RepositorySummary>, source: string, diagnostics: Array<Diagnostic>, };
export type RepositoryDetailResponse = { workspace_id: string, item: RepositorySummary, source: string, };
export type RepositorySshConnectionProbeRequest = { runtime_id: string, }; export type RepositorySshConnectionProbeRequest = { runtime_id: string, };
export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, }; export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, };
@@ -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<GitRemoteSummary>; status: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; 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<Diagnostic>; items: Array<RepositorySummary>; 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<RepositoryDiagnostic> | 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; };
+2 -2
View File
@@ -16,7 +16,7 @@ export type ApiResult<T> = {
export type { SkillCatalogResponse, SkillDetailResponse }; export type { SkillCatalogResponse, SkillDetailResponse };
type JsonLoadPolicy = { export type JsonLoadPolicy = {
diagnosticLabel: string; diagnosticLabel: string;
maxResponseBytes: number; maxResponseBytes: number;
}; };
@@ -133,7 +133,7 @@ export async function loadJson<T>(
} }
} }
async function readBoundedJson( export async function readBoundedJson(
response: Response, response: Response,
maxBytes: number, maxBytes: number,
): Promise<unknown> { ): Promise<unknown> {
@@ -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<ApiResult<RepositoryListResponse>> {
return loadJson(
fetchFn,
workspaceRepositoryListPath(workspaceId),
undefined,
parseRepositoryListResponse,
REPOSITORY_API_LOAD_POLICY,
);
}
export async function loadWorkspaceRepositoryDetail(
fetchFn: typeof fetch,
workspaceId: string,
repositoryKey: string,
): Promise<ApiResult<RepositoryDetailResponse>> {
return loadJson(
fetchFn,
workspaceRepositoryDetailPath(workspaceId, repositoryKey),
undefined,
parseRepositoryDetailResponse,
REPOSITORY_API_LOAD_POLICY,
);
}
export async function createWorkspaceRepository(
fetchFn: typeof fetch,
workspaceId: string,
request: CreateWorkspaceRepositoryRequest,
): Promise<ApiResult<CreateWorkspaceRepositoryResponse>> {
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" };
}
}
@@ -1,11 +1,11 @@
import { import {
parseRepositoryListResponse,
parseWorkspaceCatalogResponse, parseWorkspaceCatalogResponse,
parseWorkspaceCreateResponse, parseWorkspaceCreateResponse,
type RepositorySummary, type RepositorySummary,
type WorkspaceCreateResponse, type WorkspaceCreateResponse,
type WorkspaceSummary, type WorkspaceSummary,
} from "$lib/workspace/api/workspace-model"; } from "$lib/workspace/api/workspace-model";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts";
export type WorkspaceCatalogRecord = WorkspaceSummary; export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
@@ -48,12 +48,14 @@ export async function listWorkspaceRepositories(
fetcher: Fetch, fetcher: Fetch,
workspaceId: string, workspaceId: string,
): Promise<RepositorySummary[]> { ): Promise<RepositorySummary[]> {
return parseRepositoryListResponse( const result = await loadWorkspaceRepositoryList(fetcher, workspaceId);
await fetchJson( if (result.data === null) {
fetcher, throw new WorkspaceCatalogError(
`/api/w/${encodeURIComponent(workspaceId)}/repositories`, null,
), result.error ?? "Repository API request failed",
).items; );
}
return result.data.items;
} }
export async function loadWorkspaceCatalog( export async function loadWorkspaceCatalog(
@@ -1,19 +1,10 @@
import type { ApiResult } from "$lib/workspace/api/http"; import type { ApiResult } from "$lib/workspace/api/http";
import type { import type {
Diagnostic,
GitCommitSummary, GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryDiagnostic,
RepositoryListResponse,
RepositoryLogResponse, RepositoryLogResponse,
RepositorySource,
RepositorySourceKind,
RepositorySshConnectionProbeResponse, RepositorySshConnectionProbeResponse,
RepositorySshConnectionTrustState, RepositorySshConnectionTrustState,
RepositorySshHostKeyCandidate, RepositorySshHostKeyCandidate,
RepositorySummary,
WorkspaceAuthConfig, WorkspaceAuthConfig,
WorkspaceCatalogListResponse, WorkspaceCatalogListResponse,
WorkspaceCreateResponse, WorkspaceCreateResponse,
@@ -30,17 +21,25 @@ import type {
WorkspaceResponse, WorkspaceResponse,
WorkspaceSummary, WorkspaceSummary,
} from "$lib/generated/legacy-server-api.ts"; } 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 { export type {
GitCommitSummary, GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryListResponse,
RepositoryLogResponse, RepositoryLogResponse,
RepositorySshConnectionProbeResponse, RepositorySshConnectionProbeResponse,
RepositorySshHostKeyCandidate, RepositorySshHostKeyCandidate,
RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCatalogListResponse,
WorkspaceCreateResponse, WorkspaceCreateResponse,
WorkspaceDeletionOperationResponse, WorkspaceDeletionOperationResponse,
@@ -49,6 +48,18 @@ export type {
WorkspaceResponse, WorkspaceResponse,
WorkspaceSummary, WorkspaceSummary,
} from "$lib/generated/legacy-server-api.ts"; } 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<string, unknown>; type JsonObject = Record<string, unknown>;
@@ -62,6 +73,11 @@ const SOURCE_KINDS = new Set<RepositorySourceKind>([
const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]); const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); 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 { function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) { if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${path} must be an object`); 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); 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( function optionalNullableString(
value: unknown, value: unknown,
path: string, path: string,
@@ -102,6 +138,15 @@ function optionalNullableString(
return value === undefined ? undefined : nullableString(value, path); 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( function exactKeys(
value: JsonObject, value: JsonObject,
keys: readonly string[], 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( function repositoryDiagnostic(
value: unknown, value: unknown,
path: string, path: string,
@@ -135,22 +194,22 @@ function repositoryDiagnostic(
const item = object(value, path); const item = object(value, path);
exactKeys(item, ["severity", "code", "message"], path); exactKeys(item, ["severity", "code", "message"], path);
return { return {
severity: string(item.severity, `${path}.severity`), severity: repositoryString(item.severity, `${path}.severity`),
code: string(item.code, `${path}.code`), code: repositoryString(item.code, `${path}.code`),
message: string(item.message, `${path}.message`), message: repositoryString(item.message, `${path}.message`),
}; };
} }
function repositorySource(value: unknown, path: string): RepositorySource { function repositorySource(value: unknown, path: string): RepositorySource {
const source = object(value, path); const source = object(value, path);
exactKeys(source, ["kind", "uri"], 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)) { if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) {
throw new Error(`${path}.kind is invalid`); throw new Error(`${path}.kind is invalid`);
} }
return { return {
kind: kind as RepositorySourceKind, 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); const remote = object(value, path);
exactKeys(remote, ["name", "fetch_url"], path); exactKeys(remote, ["name", "fetch_url"], path);
return { return {
name: string(remote.name, `${path}.name`), name: repositoryString(remote.name, `${path}.name`),
fetch_url: string(remote.fetch_url, `${path}.fetch_url`), 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); const git = object(value, path);
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path); exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
return { return {
status: string(git.status, `${path}.status`), status: repositoryString(git.status, `${path}.status`),
head: nullableString(git.head, `${path}.head`), head: nullableRepositoryString(git.head, `${path}.head`),
branch: nullableString(git.branch, `${path}.branch`), branch: nullableRepositoryString(git.branch, `${path}.branch`),
dirty: boolean(git.dirty, `${path}.dirty`), dirty: boolean(git.dirty, `${path}.dirty`),
remotes: array(git.remotes, `${path}.remotes`).map((item, index) => remotes: repositoryArray(git.remotes, `${path}.remotes`).map((
gitRemote(item, `${path}.remotes[${index}]`) item,
), index,
) => gitRemote(item, `${path}.remotes[${index}]`)),
}; };
} }
@@ -197,7 +257,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
], ],
path, path,
); );
const observedStatus = string( const observedStatus = repositoryString(
item.observed_status, item.observed_status,
`${path}.observed_status`, `${path}.observed_status`,
); );
@@ -207,29 +267,36 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
const diagnostics = const diagnostics =
item.diagnostics === undefined || item.diagnostics === null item.diagnostics === undefined || item.diagnostics === null
? item.diagnostics ? item.diagnostics
: array(item.diagnostics, `${path}.diagnostics`).map((entry, index) => : repositoryArray(item.diagnostics, `${path}.diagnostics`).map((
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`) entry,
); index,
) => repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`));
return { return {
repository_key: string(item.repository_key, `${path}.repository_key`), repository_key: repositoryString(
kind: string(item.kind, `${path}.kind`), item.repository_key,
provider: string(item.provider, `${path}.provider`), `${path}.repository_key`,
),
kind: repositoryString(item.kind, `${path}.kind`),
provider: repositoryString(item.provider, `${path}.provider`),
source: repositorySource(item.source, `${path}.source`), source: repositorySource(item.source, `${path}.source`),
source_revision: integer(item.source_revision, `${path}.source_revision`), source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string( source_fingerprint: repositoryString(
item.source_fingerprint, item.source_fingerprint,
`${path}.source_fingerprint`, `${path}.source_fingerprint`,
), ),
observed_status: observedStatus as RepositorySummary["observed_status"], observed_status: observedStatus as RepositorySummary["observed_status"],
observed_at: optionalNullableString( observed_at: optionalNullableRepositoryString(
item.observed_at, item.observed_at,
`${path}.observed_at`, `${path}.observed_at`,
), ),
default_selector: optionalNullableString( default_selector: optionalNullableRepositoryString(
item.default_selector, item.default_selector,
`${path}.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 git: item.git === undefined || item.git === null
? item.git ? item.git
: gitSummary(item.git, `${path}.git`), : gitSummary(item.git, `${path}.git`),
@@ -287,7 +354,7 @@ function workspaceRepositoryRecord(
], ],
path, path,
); );
const observedStatus = string( const observedStatus = repositoryString(
item.observed_status, item.observed_status,
`${path}.observed_status`, `${path}.observed_status`,
); );
@@ -302,7 +369,7 @@ function workspaceRepositoryRecord(
source: repositorySource(item.source, `${path}.source`), source: repositorySource(item.source, `${path}.source`),
default_ref: nullableString(item.default_ref, `${path}.default_ref`), default_ref: nullableString(item.default_ref, `${path}.default_ref`),
source_revision: integer(item.source_revision, `${path}.source_revision`), source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string( source_fingerprint: repositoryString(
item.source_fingerprint, item.source_fingerprint,
`${path}.source_fingerprint`, `${path}.source_fingerprint`,
), ),
@@ -533,21 +600,28 @@ export function parseRepositoryListResponse(
"repository list response", "repository list response",
); );
return { return {
workspace_id: string( workspace_id: repositoryString(
response.workspace_id, response.workspace_id,
"repository list response.workspace_id", "repository list response.workspace_id",
), ),
items: array(response.items, "repository list response.items").map(( items: repositoryArray(response.items, "repository list response.items")
item, .map((
index, item,
) => repositorySummary(item, `repository list response.items[${index}]`)), index,
source: string(response.source, "repository list response.source"), ) => repositorySummary(item, `repository list response.items[${index}]`)),
diagnostics: array( source: repositoryString(
response.source,
"repository list response.source",
),
diagnostics: repositoryArray(
response.diagnostics, response.diagnostics,
"repository list response.diagnostics", "repository list response.diagnostics",
).map( ).map(
(item, index) => (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", "repository detail response",
); );
return { return {
workspace_id: string( workspace_id: repositoryString(
response.workspace_id, response.workspace_id,
"repository detail response.workspace_id", "repository detail response.workspace_id",
), ),
item: repositorySummary(response.item, "repository detail response.item"), 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",
),
}; };
} }
@@ -1,16 +1,14 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
parseRepositoryListResponse, import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model";
parseWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => { export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const [workspaceResult, repositoryResult] = await Promise.all([ const [workspaceResult, repositoryResult] = await Promise.all([
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")), loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")), loadWorkspaceRepositoryList(fetch, workspaceId),
]); ]);
let workspace = null; let workspace = null;
@@ -30,17 +28,8 @@ export const load: LayoutLoad = async ({ fetch, params }) => {
}); });
} }
let repositories = null; const repositories = repositoryResult.data;
let repositoriesError = repositoryResult.error; const repositoriesError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repositories = parseRepositoryListResponse(repositoryResult.data);
} catch (cause) {
repositoriesError = cause instanceof Error
? cause.message
: "invalid repository list response";
}
}
return { return {
workspace, workspace,
@@ -1,22 +1,14 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { loadWorkspaceRepositoryDetail } from "$lib/workspace/api/repositories";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { import { parseRepositoryLogResponse } from "$lib/workspace/api/workspace-model";
parseRepositoryDetailResponse,
parseRepositoryLogResponse,
} from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const repositoryKey = params.repositoryKey; const repositoryKey = params.repositoryKey;
const [repositoryResult, logResult, runtimesResult] = await Promise.all([ const [repositoryResult, logResult, runtimesResult] = await Promise.all([
loadJson<unknown>( loadWorkspaceRepositoryDetail(fetch, workspaceId, repositoryKey),
fetch,
workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryKey)}`,
),
),
loadJson<unknown>( loadJson<unknown>(
fetch, fetch,
workspaceApiPath( workspaceApiPath(
@@ -30,17 +22,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
), ),
]); ]);
let repository = null; const repository = repositoryResult.data;
let repositoryError = repositoryResult.error; const repositoryError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repository = parseRepositoryDetailResponse(repositoryResult.data);
} catch (cause) {
repositoryError = cause instanceof Error
? cause.message
: "invalid repository detail response";
}
}
let log = null; let log = null;
let logError = logResult.error; let logError = logResult.error;
@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { workspaceApiPath, workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '$lib/workspace/api/http';
import type { RepositorySourceKind } from '$lib/generated/legacy-server-api'; import { createWorkspaceRepository } from '$lib/workspace/api/repositories';
import type {
CreateWorkspaceRepositoryRequest,
RepositorySourceKind,
} from '$lib/generated/repository-api';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -22,38 +26,27 @@
let pending = $state(false); let pending = $state(false);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
}
async function addRepository(event: SubmitEvent): Promise<void> { async function addRepository(event: SubmitEvent): Promise<void> {
event.preventDefault(); event.preventDefault();
pending = true; pending = true;
requestError = null; requestError = null;
try { const request: CreateWorkspaceRepositoryRequest = {
const response = await fetch(workspaceApiPath(data.workspaceId, '/repositories'), { repository_key: repositoryKey,
method: 'POST', source,
headers: { 'content-type': 'application/json' }, default_ref: defaultRef || null,
body: JSON.stringify({ };
repository_key: repositoryKey, const result = await createWorkspaceRepository(fetch, data.workspaceId, request);
source, if (result.data === null) {
default_ref: defaultRef || null, requestError = result.error ?? 'Repository creation failed';
}),
});
if (!response.ok) throw new Error(await responseError(response));
repositoryKey = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
pending = false; pending = false;
return;
} }
repositoryKey = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
pending = false;
} }
</script> </script>
@@ -1,13 +1,10 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { parseRepositoryListResponse } from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const repositories = await loadJson( const repositories = await loadWorkspaceRepositoryList(
fetch, fetch,
workspaceApiPath(params.workspaceId, "/repositories"), params.workspaceId,
undefined,
parseRepositoryListResponse,
); );
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
@@ -1,6 +1,6 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model"; import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { import {
canonicalResourceReference, canonicalResourceReference,
resourceKey, resourceKey,
@@ -17,10 +17,7 @@ export const load = (async ({ fetch, params }) => {
); );
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([ const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath), loadJson<TicketDetail>(fetch, ticketPath),
loadJson<unknown>( loadWorkspaceRepositoryList(fetch, params.workspaceId),
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
loadJson<WorkspaceOrchestratorStatus>( loadJson<WorkspaceOrchestratorStatus>(
fetch, fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"), workspaceApiPath(params.workspaceId, "/orchestrator"),
@@ -40,7 +37,7 @@ export const load = (async ({ fetch, params }) => {
); );
} }
} }
const repositories = parseRepositoryListApiResult(repositoriesRaw); const repositories = repositoriesRaw;
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
+25 -15
View File
@@ -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", () => { test("Repository settings use the scoped list and typed create collection", () => {
for (const token of [ const source = `${pageSource}\n${loadSource}\n${apiSource}`;
"workspaceApiPath(params.workspaceId, \"/repositories\")", for (
"workspaceApiPath(data.workspaceId, '/repositories')", const token of [
"method: 'POST'", "loadWorkspaceRepositoryList",
"repository_key: repositoryKey", "createWorkspaceRepository",
"default_ref: defaultRef || null", "workspaceRepositoryListPath(workspaceId)",
"await invalidateAll()", 'method: "POST"',
]) { "repository_key: repositoryKey",
"default_ref: defaultRef || null",
"await invalidateAll()",
]
) {
assert( assert(
pageSource.includes(token) || loadSource.includes(token), source.includes(token),
`Repository settings should include ${token}`, `Repository settings should include ${token}`,
); );
} }
}); });
test("Repository Add form keeps access secrets outside registration input", () => { test("Repository Add form keeps access secrets outside registration input", () => {
for (const forbidden of [ for (
"private_key", const forbidden of [
"passphrase", "private_key",
"credential_id", "passphrase",
"host_trust_id", "credential_id",
]) { "host_trust_id",
]
) {
assert( assert(
!pageSource.includes(forbidden), !pageSource.includes(forbidden),
`Repository registration must not accept ${forbidden}`, `Repository registration must not accept ${forbidden}`,
+136
View File
@@ -0,0 +1,136 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): 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}`);
}
});
+82 -2
View File
@@ -4,11 +4,14 @@ declare const Deno: {
}; };
import { import {
parseCreateWorkspaceRepositoryResponse,
parseRepositoryApiError,
parseRepositoryListApiResult, parseRepositoryListApiResult,
parseRepositoryListResponse, parseRepositoryListResponse,
parseWorkspaceDeletionOperationResponse, parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse, parseWorkspaceDeletionPreflightResponse,
parseWorkspaceResponse, parseWorkspaceResponse,
REPOSITORY_API_LIMITS,
} from "../src/lib/workspace/api/workspace-model.ts"; } from "../src/lib/workspace/api/workspace-model.ts";
function assertThrows(operation: () => unknown, expected: string): void { 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", () => { Deno.test("repository API result converts stale payloads into bounded page errors", () => {
const result = parseRepositoryListApiResult({ const result = parseRepositoryListApiResult({
data: { 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 () => { 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( Deno.readTextFile(
new URL( new URL(
"../src/routes/w/[workspaceId]/settings/repositories/+page.ts", "../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, 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 ( for (
const token of [ const token of [
"loadWorkspaceRepositoryList",
"createWorkspaceRepository",
"parseRepositoryListResponse", "parseRepositoryListResponse",
"parseCreateWorkspaceRepositoryResponse",
"parseRepositoryApiError",
"repository.repository_key", "repository.repository_key",
"repository.observed_status", "repository.observed_status",
"sourceLabel(repository.source.kind)", "sourceLabel(repository.source.kind)",
"supportsRepositoryAccess(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}`); 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"]) { for (const kind of ["ssh", "https"]) {
if (!pageSource.includes(`kind === '${kind}'`)) { if (!pageSource.includes(`kind === '${kind}'`)) {
throw new Error(`Repository Access should support ${kind}`); throw new Error(`Repository Access should support ${kind}`);