|
|
|
@@ -0,0 +1,609 @@
|
|
|
|
|
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 schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> {
|
|
|
|
|
const ALLOWED_REFERENCE_SIBLINGS: &[&str] =
|
|
|
|
|
&["$ref", "$comment", "deprecated", "description", "title"];
|
|
|
|
|
let schema = object(value, context)?;
|
|
|
|
|
if let Some(keyword) = schema
|
|
|
|
|
.keys()
|
|
|
|
|
.find(|keyword| !ALLOWED_REFERENCE_SIBLINGS.contains(&keyword.as_str()))
|
|
|
|
|
{
|
|
|
|
|
return Err(GenerationError::invalid(format!(
|
|
|
|
|
"{context} contains unsupported `$ref` sibling `{keyword}`"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
schema.get("$ref").and_then(Value::as_str).ok_or_else(|| {
|
|
|
|
|
GenerationError::invalid(format!("{context} must use a string schema reference"))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn require_schema_ref(value: &Value, expected: &str, context: &str) -> Result<(), GenerationError> {
|
|
|
|
|
let reference = schema_reference(value, context)?;
|
|
|
|
|
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 object.contains_key("$ref") {
|
|
|
|
|
let reference = schema_reference(value, context)?;
|
|
|
|
|
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 schema.contains_key("$ref") {
|
|
|
|
|
let reference = schema_reference(value, context)?;
|
|
|
|
|
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_rejects_lossy_reference_siblings() {
|
|
|
|
|
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
|
|
|
|
document["components"]["schemas"]["RepositorySummary"]["properties"]["source"]["maxLength"] =
|
|
|
|
|
serde_json::json!(1);
|
|
|
|
|
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
|
|
|
|
assert!(error.to_string().contains("unsupported `$ref` sibling"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn generator_rejects_lossy_operation_reference_siblings() {
|
|
|
|
|
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
|
|
|
|
document["paths"]["/api/repositories"]["get"]["responses"]["200"]["content"]["application/json"]
|
|
|
|
|
["schema"]["maxItems"] = serde_json::json!(1);
|
|
|
|
|
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
|
|
|
|
|
assert!(error.to_string().contains("unsupported `$ref` sibling"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
}
|