//! Portable Capability Descriptor validation and binding generation.
//!
//! The Descriptor and its package-local JSON Schemas are the only portable
//! contract source. This crate deliberately lives above the Kernel: it reads
//! authoring files, checks evolution rules, and emits deterministic Rust and
//! TypeScript artifacts before an App is booted.
use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fmt,
fmt::Write as _,
fs,
path::{Path, PathBuf},
};
use serde_json::{Map, Value};
mod browser;
mod ir;
mod rust_debug;
pub use browser::generate_browser_request_client;
use ir::{ContractIr, ErrorVariantIr, FieldIr, ObjectAdditionalIr, OperationIr, TypeIr};
const GENERATED_HEADER: &str = "// @generated by lenso-contract-codegen; do not edit.\n";
const TYPESCRIPT_HEADER: &str = "/* @generated by lenso-contract-codegen; do not edit. */\n";
const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
/// A parsed Descriptor `SemVer`.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct Version(semver::Version);
impl Version {
fn parse(value: &str) -> Result<Self, CodegenError> {
semver::Version::parse(value)
.map(Self)
.map_err(|error| CodegenError::InvalidDescriptor {
detail: format!("Descriptor version `{value}` is not valid SemVer: {error}"),
})
}
const fn major(&self) -> u64 {
self.0.major
}
const fn minor(&self) -> u64 {
self.0.minor
}
}
fn parse_numeric_component(value: &str) -> Option<u64> {
(!value.is_empty() && (value == "0" || !value.starts_with('0')))
.then(|| value.parse().ok())
.flatten()
}
#[derive(Clone, Debug)]
struct Operation {
name: String,
interaction: String,
request_schema: Value,
response_schema: Value,
domain_error_schema: Value,
}
fn contract_ir(descriptor: &Descriptor) -> ContractIr {
ContractIr {
capability_id: descriptor.capability_id.clone(),
version: descriptor.version.clone(),
portable: descriptor.portable,
cross_lane_transfer: descriptor.cross_lane_transfer,
operations: descriptor
.operations
.iter()
.map(|operation| OperationIr {
name: operation.name.clone(),
interaction: operation.interaction.clone(),
request: type_ir_from_schema(&operation.request_schema),
response: type_ir_from_schema(&operation.response_schema),
domain_errors: error_variant_ir_definitions(&operation.domain_error_schema),
})
.collect(),
}
}
fn type_ir_from_schema(schema: &Value) -> TypeIr {
if let Some(types) = schema.get("type").and_then(Value::as_array) {
let non_null_types = types
.iter()
.filter(|schema_type| *schema_type != "null")
.cloned()
.collect::<Vec<_>>();
if non_null_types.is_empty() {
return TypeIr::Null;
}
if non_null_types.len() == 1 {
let mut narrowed = schema.as_object().cloned().unwrap_or_default();
narrowed.insert("type".to_owned(), non_null_types[0].clone());
let base = type_ir_non_null(&Value::Object(narrowed));
return if non_null_types.len() == types.len() {
base
} else {
TypeIr::Nullable(Box::new(base))
};
}
}
type_ir_non_null(schema)
}
fn type_ir_non_null(schema: &Value) -> TypeIr {
let Some(object) = schema.as_object() else {
return TypeIr::Any;
};
if let Some(values) = object.get("enum").and_then(Value::as_array) {
return TypeIr::Enum(
values
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect(),
);
}
if let Some(schema_type) = object.get("type").and_then(Value::as_str) {
return match schema_type {
"object" => {
let required = required_fields(schema);
let fields = object
.get("properties")
.and_then(Value::as_object)
.into_iter()
.flatten()
.map(|(name, schema)| FieldIr {
name: name.clone(),
required: required.contains(name),
sensitive: schema
.get("x-lenso-sensitive")
.and_then(Value::as_bool)
.unwrap_or(false),
ty: type_ir_from_schema(schema),
})
.collect();
let additional = match object.get("additionalProperties") {
Some(Value::Bool(false)) => ObjectAdditionalIr::Closed,
Some(Value::Bool(true)) | None => ObjectAdditionalIr::Any,
Some(schema) => {
ObjectAdditionalIr::Typed(Box::new(type_ir_from_schema(schema)))
}
};
TypeIr::Object { fields, additional }
}
"array" => object.get("items").map_or(TypeIr::Any, |items| {
TypeIr::Array(Box::new(type_ir_from_schema(items)))
}),
"string" => match object.get("format").and_then(Value::as_str) {
Some("int64") => TypeIr::Int64,
Some("uint64") => TypeIr::Uint64,
Some("byte") => TypeIr::Bytes,
Some("date-time") => TypeIr::Timestamp,
Some("duration") => TypeIr::Duration,
_ => TypeIr::String,
},
"integer" => TypeIr::Integer,
"number" => TypeIr::Number,
"boolean" => TypeIr::Boolean,
"null" => TypeIr::Null,
_ => TypeIr::Any,
};
}
TypeIr::Any
}
/// A validated, self-contained portable Capability Descriptor.
#[derive(Clone, Debug)]
pub struct Descriptor {
capability_id: String,
capability_major: u64,
version: String,
parsed_version: Version,
portable: bool,
cross_lane_transfer: bool,
operations: Vec<Operation>,
}
impl Descriptor {
/// Returns the stable Capability series identity, including its major.
#[must_use]
pub fn capability_id(&self) -> &str {
&self.capability_id
}
/// Returns the major encoded in the Capability identity.
#[must_use]
pub const fn capability_major(&self) -> u64 {
self.capability_major
}
/// Returns the exact Descriptor `SemVer` selected for generation.
#[must_use]
pub fn version(&self) -> &str {
&self.version
}
/// Returns whether the Capability is intended to cross Runtime Adapters.
#[must_use]
pub const fn portable(&self) -> bool {
self.portable
}
/// Returns whether generated native values support transfer across Execution Lanes.
#[must_use]
pub const fn cross_lane_transfer(&self) -> bool {
self.cross_lane_transfer
}
/// Returns the stable Operation names in deterministic lexical order.
#[must_use]
pub fn operation_names(&self) -> Vec<&str> {
self.operations
.iter()
.map(|operation| operation.name.as_str())
.collect()
}
}
/// Metadata embedded in every generated language artifact.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedMetadata {
/// Stable `namespace.name@major` identity.
pub capability_id: String,
/// Exact Descriptor `SemVer`.
pub descriptor_version: String,
/// Whether the source Descriptor is portable.
pub portable: bool,
/// Whether generated native values may transfer across Execution Lanes.
pub cross_lane_transfer: bool,
}
/// Deterministic artifacts generated from one Descriptor and Schema source.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedArtifacts {
/// Metadata shared by all generated languages.
pub metadata: GeneratedMetadata,
/// Rust provider, client, value, and error bindings.
pub rust: String,
/// TypeScript provider, client, value, and error bindings.
pub typescript: String,
}
/// Errors produced while parsing, validating, or generating a contract.
#[derive(Debug)]
pub enum CodegenError {
/// A file could not be read.
Io {
path: PathBuf,
source: std::io::Error,
},
/// Descriptor or Schema data is malformed.
InvalidDescriptor { detail: String },
/// A Schema cannot be used by the portable value profile.
UnsupportedSchema { path: PathBuf, detail: String },
/// A Descriptor interaction is not supported by the generated language bindings.
UnsupportedInteraction {
operation: String,
interaction: String,
},
/// A JSON value would be lossy when represented by JavaScript numbers.
InvalidPortableValue { path: String, detail: String },
/// A checked-in generated artifact differs from its source.
GeneratedArtifactDrift { path: PathBuf },
}
impl fmt::Display for CodegenError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
Self::InvalidDescriptor { detail } => {
write!(formatter, "invalid Descriptor: {detail}")
}
Self::UnsupportedSchema { path, detail } => {
write!(formatter, "unsupported Schema {}: {detail}", path.display())
}
Self::UnsupportedInteraction {
operation,
interaction,
} => write!(
formatter,
"Operation `{operation}` uses unsupported interaction `{interaction}`"
),
Self::InvalidPortableValue { path, detail } => {
write!(formatter, "invalid portable value at `{path}`: {detail}")
}
Self::GeneratedArtifactDrift { path } => {
write!(formatter, "generated artifact is stale: {}", path.display())
}
}
}
}
impl Error for CodegenError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
/// Reasons why a Descriptor evolution is not safe for an existing Capability
/// series.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CompatibilityError {
/// The two files do not describe the same Capability series.
IdentityChanged { from: String, to: String },
/// The new Descriptor is not newer than the old one.
VersionNotAdvanced { from: String, to: String },
/// The change is not allowed by the old series and requires a new major.
BreakingChanges { changes: Vec<String> },
}
impl fmt::Display for CompatibilityError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::IdentityChanged { from, to } => {
write!(
formatter,
"Capability identity changed from `{from}` to `{to}`"
)
}
Self::VersionNotAdvanced { from, to } => {
write!(
formatter,
"Descriptor version must advance from `{from}` to `{to}`"
)
}
Self::BreakingChanges { changes } => write!(
formatter,
"breaking Descriptor changes require a new major: {}",
changes.join("; ")
),
}
}
}
impl Error for CompatibilityError {}
/// Reads and validates one Descriptor, resolving package-local `$ref` files.
#[allow(clippy::too_many_lines)]
pub fn load_descriptor(path: &Path) -> Result<Descriptor, CodegenError> {
let descriptor_path = canonical_path(path)?;
let package_root = descriptor_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let descriptor_value = read_json(&descriptor_path)?;
let object = descriptor_value
.as_object()
.ok_or_else(|| CodegenError::InvalidDescriptor {
detail: "the Descriptor root must be an object".to_owned(),
})?;
let capability_id = required_string(object, "id")?;
let (identity, identity_major) = capability_identity(&capability_id)?;
let version = required_string(object, "version")?;
let parsed_version = Version::parse(&version)?;
let portable = object
.get("portable")
.and_then(Value::as_bool)
.unwrap_or(false);
let cross_lane_transfer = object
.get("cross_lane_transfer")
.and_then(Value::as_bool)
.unwrap_or(false);
let operation_values = object
.get("operations")
.and_then(Value::as_array)
.ok_or_else(|| CodegenError::InvalidDescriptor {
detail: "`operations` must be an array".to_owned(),
})?;
if operation_values.is_empty() {
return Err(CodegenError::InvalidDescriptor {
detail: "a Descriptor must declare at least one Operation".to_owned(),
});
}
let mut operations = Vec::with_capacity(operation_values.len());
let mut operation_names = BTreeSet::new();
let mut generated_operation_names = BTreeSet::new();
let mut generated_type_names = BTreeSet::new();
let mut generated_client_method_names =
BTreeSet::from(["new".to_owned(), "from_dependencies".to_owned()]);
let capability_name = identity
.rsplit('.')
.next()
.map_or_else(|| "Capability".to_owned(), pascal_case);
for operation_value in operation_values {
let operation =
operation_value
.as_object()
.ok_or_else(|| CodegenError::InvalidDescriptor {
detail: "each Operation must be an object".to_owned(),
})?;
let name = required_string(operation, "name")?;
if !is_identifier(&name) || !is_rust_member_name(&name) {
return Err(CodegenError::InvalidDescriptor {
detail: format!(
"Operation name `{name}` must start with a letter or `_` and contain only letters, digits, `_`, or `-`"
),
});
}
if !operation_names.insert(name.clone()) {
return Err(CodegenError::InvalidDescriptor {
detail: format!("Operation `{name}` is declared more than once"),
});
}
for generated_name in [rust_field_name(&name), pascal_case(&name)] {
if !generated_operation_names.insert(generated_name.clone()) {
return Err(CodegenError::InvalidDescriptor {
detail: format!(
"Operation name `{name}` collides after code generation as `{generated_name}`"
),
});
}
}
let operation_name = pascal_case(&name);
let mut generated_names = vec![
format!("{operation_name}Request"),
format!("{operation_name}Response"),
format!("{operation_name}Error"),
format!("{operation_name}InvocationError"),
format!("{operation_name}Result"),
format!("{capability_name}{operation_name}"),
];
generated_names.push(if operation_values.len() == 1 {
format!("{capability_name}InvocationError")
} else {
format!("{capability_name}{operation_name}InvocationError")
});
for generated_name in generated_names {
if !is_generated_type_name(&generated_name)
|| !generated_type_names.insert(generated_name.clone())
{
return Err(CodegenError::InvalidDescriptor {
detail: format!(
"Operation name `{name}` collides after type generation as `{generated_name}`"
),
});
}
}
let interaction = required_string(operation, "interaction")?;
if !matches!(interaction.as_str(), "request" | "stream" | "event") {
return Err(CodegenError::InvalidDescriptor {
detail: format!("Operation `{name}` has unsupported interaction `{interaction}`"),
});
}
if matches!(interaction.as_str(), "request" | "stream" | "event") {
let client_method_name = rust_field_name(&name);
for generated_name in [
client_method_name.clone(),
format!("{client_method_name}_with_context"),
] {
if !generated_client_method_names.insert(generated_name.clone()) {
return Err(CodegenError::InvalidDescriptor {
detail: format!(
"Operation name `{name}` collides with the generated Client API as `{generated_name}`"
),
});
}
}
}
let request_schema_path =
schema_path(&descriptor_path, operation, "request_schema", &name)?;
let response_schema_path =
schema_path(&descriptor_path, operation, "response_schema", &name)?;
let domain_error_schema_path =
schema_path(&descriptor_path, operation, "domain_error_schema", &name)?;
let mut ref_stack = Vec::new();
let request_source = read_json(&request_schema_path)?;
let request_schema = resolve_refs(
&request_source,
&request_source,
&request_schema_path,
&package_root,
&mut ref_stack,
)?;
validate_schema_profile(&request_schema, &request_schema_path)?;
validate_value_generation_schema(&request_schema, &request_schema_path)?;
let mut ref_stack = Vec::new();
let response_source = read_json(&response_schema_path)?;
let response_schema = resolve_refs(
&response_source,
&response_source,
&response_schema_path,
&package_root,
&mut ref_stack,
)?;
validate_schema_profile(&response_schema, &response_schema_path)?;
validate_value_generation_schema(&response_schema, &response_schema_path)?;
let mut ref_stack = Vec::new();
let domain_error_source = read_json(&domain_error_schema_path)?;
let domain_error_schema = resolve_refs(
&domain_error_source,
&domain_error_source,
&domain_error_schema_path,
&package_root,
&mut ref_stack,
)?;
validate_schema_profile(&domain_error_schema, &domain_error_schema_path)?;
validate_domain_error_schema(&domain_error_schema, &domain_error_schema_path)?;
operations.push(Operation {
name,
interaction,
request_schema,
response_schema,
domain_error_schema,
});
}
operations.sort_by(|left, right| left.name.cmp(&right.name));
Ok(Descriptor {
capability_id: format!("{identity}@{identity_major}"),
capability_major: identity_major,
version,
parsed_version,
portable,
cross_lane_transfer,
operations,
})
}
fn required_string(object: &Map<String, Value>, key: &str) -> Result<String, CodegenError> {
object
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| CodegenError::InvalidDescriptor {
detail: format!("`{key}` must be a non-empty string"),
})
}
fn capability_identity(value: &str) -> Result<(String, u64), CodegenError> {
let Some((identity, major)) = value.rsplit_once('@') else {
return Err(CodegenError::InvalidDescriptor {
detail: format!("Capability id `{value}` must use `namespace.name@major`"),
});
};
let generated_name = identity
.rsplit('.')
.next()
.map(pascal_case)
.unwrap_or_default();
if identity.is_empty()
|| identity.split('.').count() < 2
|| !identity.split('.').all(is_identifier)
|| !is_generated_type_name(&generated_name)
{
return Err(CodegenError::InvalidDescriptor {
detail: format!("Capability id `{value}` must use `namespace.name@major`"),
});
}
let Some(major) = parse_numeric_component(major) else {
return Err(CodegenError::InvalidDescriptor {
detail: format!("Capability id `{value}` has an invalid major"),
});
};
Ok((identity.to_owned(), major))
}
fn schema_path(
descriptor_path: &Path,
operation: &Map<String, Value>,
key: &str,
operation_name: &str,
) -> Result<PathBuf, CodegenError> {
let value = required_string(operation, key).map_err(|error| match error {
CodegenError::InvalidDescriptor { detail } => CodegenError::InvalidDescriptor {
detail: format!("Operation `{operation_name}`: {detail}"),
},
other => other,
})?;
let candidate = descriptor_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(value);
let canonical = canonical_path(&candidate)?;
let package_root = canonical_path(descriptor_path.parent().unwrap_or_else(|| Path::new(".")))?;
if !canonical.starts_with(&package_root) {
return Err(CodegenError::UnsupportedSchema {
path: canonical,
detail: "Schema paths must remain inside the Descriptor package".to_owned(),
});
}
Ok(canonical)
}
fn canonical_path(path: &Path) -> Result<PathBuf, CodegenError> {
fs::canonicalize(path).map_err(|source| CodegenError::Io {
path: path.to_path_buf(),
source,
})
}
fn read_json(path: &Path) -> Result<Value, CodegenError> {
let source = fs::read_to_string(path).map_err(|source| CodegenError::Io {
path: path.to_path_buf(),
source,
})?;
serde_json::from_str(&source).map_err(|error| CodegenError::InvalidDescriptor {
detail: format!("{}: {error}", path.display()),
})
}
fn resolve_refs(
value: &Value,
root: &Value,
source_path: &Path,
package_root: &Path,
stack: &mut Vec<(PathBuf, String)>,
) -> Result<Value, CodegenError> {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
let target_value = if reference.starts_with('#') {
let fragment = reference.strip_prefix('#').unwrap_or_default();
let target_path = source_path.to_path_buf();
let key = (target_path.clone(), reference.to_owned());
if stack.contains(&key) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("cyclic local Schema `$ref` `{reference}`"),
});
}
let target = json_pointer(root, fragment).ok_or_else(|| {
CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!(
"local JSON Pointer `$ref` `{reference}` was not found"
),
}
})?;
stack.push(key);
let resolved = resolve_refs(target, root, source_path, package_root, stack)?;
stack.pop();
resolved
} else {
let (reference_path, fragment) =
reference.split_once('#').unwrap_or((reference, ""));
let target_path = source_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(reference_path);
let target_path = canonical_path(&target_path)?;
if !target_path.starts_with(package_root) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!(
"external Schema `$ref` `{reference}` leaves the Descriptor package"
),
});
}
let key = (target_path.clone(), format!("#{fragment}"));
if stack.contains(&key) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!(
"cyclic Schema `$ref` through {}",
target_path.display()
),
});
}
let target_root = read_json(&target_path)?;
let target = json_pointer(&target_root, fragment).ok_or_else(|| {
CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!(
"external JSON Pointer `$ref` `{reference}` was not found"
),
}
})?;
stack.push(key);
let resolved =
resolve_refs(target, &target_root, &target_path, package_root, stack)?;
stack.pop();
resolved
};
let mut merged = target_value.as_object().cloned().ok_or_else(|| {
CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "a `$ref` target must resolve to an object Schema".to_owned(),
}
})?;
for (key, child) in object {
if key != "$ref" {
merged.insert(
key.clone(),
resolve_refs(child, root, source_path, package_root, stack)?,
);
}
}
return Ok(Value::Object(merged));
}
let mut resolved = Map::new();
for (key, child) in object {
resolved.insert(
key.clone(),
resolve_refs(child, root, source_path, package_root, stack)?,
);
}
Ok(Value::Object(resolved))
}
Value::Array(values) => values
.iter()
.map(|child| resolve_refs(child, root, source_path, package_root, stack))
.collect::<Result<Vec<_>, _>>()
.map(Value::Array),
_ => Ok(value.clone()),
}
}
fn json_pointer<'a>(root: &'a Value, fragment: &str) -> Option<&'a Value> {
if fragment.is_empty() {
return Some(root);
}
let pointer = fragment.strip_prefix('/')?;
let mut current = root;
for token in pointer.split('/') {
let token = token.replace("~1", "/").replace("~0", "~");
current = match current {
Value::Object(object) => object.get(&token)?,
Value::Array(array) => array.get(token.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(current)
}
fn validate_schema_profile(schema: &Value, source_path: &Path) -> Result<(), CodegenError> {
let Some(object) = schema.as_object() else {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "a JSON Schema must be an object".to_owned(),
});
};
if let Some(format) = object.get("format").and_then(Value::as_str) {
if !matches!(
format,
"int64" | "uint64" | "byte" | "date-time" | "duration"
) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("format `{format}` is outside the portable value profile"),
});
}
if !schema_includes_type(object, "string") {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("portable format `{format}` must be attached to a string Schema"),
});
}
}
if let Some(schema_type) = object.get("type") {
match schema_type {
Value::String(schema_type) => validate_schema_type(schema_type, object, source_path)?,
Value::Array(types) => {
if types.is_empty() {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "a Schema type union cannot be empty".to_owned(),
});
}
for schema_type in types {
let Some(schema_type) = schema_type.as_str() else {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Schema type unions must contain strings".to_owned(),
});
};
validate_schema_type(schema_type, object, source_path)?;
}
}
_ => {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Schema `type` must be a string or array".to_owned(),
});
}
}
} else if let Some(alternatives) = object
.get("oneOf")
.or_else(|| object.get("anyOf"))
.and_then(Value::as_array)
{
for alternative in alternatives {
validate_schema_profile(alternative, source_path)?;
}
} else if !object.contains_key("const") && !object.contains_key("enum") {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Schema needs a supported `type`, `oneOf`, `anyOf`, `const`, or `enum`"
.to_owned(),
});
}
if let Some(properties) = object.get("properties") {
let Some(properties) = properties.as_object() else {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Schema `properties` must be an object".to_owned(),
});
};
for property in properties.values() {
validate_schema_profile(property, source_path)?;
}
}
if let Some(items) = object.get("items") {
validate_schema_profile(items, source_path)?;
}
if let Some(additional) = object.get("additionalProperties")
&& !additional.is_boolean()
{
validate_schema_profile(additional, source_path)?;
}
if let Some(definitions) = object.get("$defs").and_then(Value::as_object) {
for definition in definitions.values() {
validate_schema_profile(definition, source_path)?;
}
}
if let Some(alternatives) = object
.get("oneOf")
.or_else(|| object.get("anyOf"))
.and_then(Value::as_array)
{
for alternative in alternatives {
validate_schema_profile(alternative, source_path)?;
}
}
Ok(())
}
fn validate_schema_type(
schema_type: &str,
schema: &Map<String, Value>,
source_path: &Path,
) -> Result<(), CodegenError> {
if !matches!(
schema_type,
"object" | "array" | "string" | "integer" | "number" | "boolean" | "null"
) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("Schema type `{schema_type}` is outside the portable profile"),
});
}
if schema_type == "array" && !schema.contains_key("items") {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "array Schemas must declare `items`".to_owned(),
});
}
Ok(())
}
fn schema_includes_type(schema: &Map<String, Value>, expected: &str) -> bool {
match schema.get("type") {
Some(Value::String(schema_type)) => schema_type == expected,
Some(Value::Array(types)) => types.iter().any(|schema_type| {
schema_type
.as_str()
.is_some_and(|schema_type| schema_type == expected)
}),
_ => false,
}
}
#[allow(clippy::too_many_lines)]
fn validate_value_generation_schema(
schema: &Value,
source_path: &Path,
) -> Result<(), CodegenError> {
let Some(object) = schema.as_object() else {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated value Schemas must be objects".to_owned(),
});
};
if object.contains_key("oneOf") || object.contains_key("anyOf") {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated value Schemas do not support oneOf/anyOf unions".to_owned(),
});
}
if object.contains_key("allOf") {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated value Schemas do not support allOf unions".to_owned(),
});
}
if object.contains_key("const") {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated value Schemas do not support const fields".to_owned(),
});
}
if let Some(types) = object.get("type").and_then(Value::as_array) {
let non_null = types
.iter()
.filter(|schema_type| *schema_type != "null")
.count();
if non_null > 1 {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated value Schemas only support nullable type unions".to_owned(),
});
}
let mut narrowed = object.clone();
let Some(schema_type) = types.iter().find(|schema_type| *schema_type != "null") else {
return Ok(());
};
narrowed.insert("type".to_owned(), schema_type.clone());
return validate_value_generation_schema(&Value::Object(narrowed), source_path);
}
if let Some(values) = object.get("enum").and_then(Value::as_array) {
if values.is_empty() || !values.iter().all(Value::is_string) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated enum values must be non-empty strings".to_owned(),
});
}
let mut enum_names = BTreeSet::new();
for value in values.iter().filter_map(Value::as_str) {
let name = pascal_case(value);
if !is_generated_enum_variant(&name) || !enum_names.insert(name) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated enum values collide after Rust variant generation"
.to_owned(),
});
}
}
}
let Some(schema_type) = object.get("type").and_then(Value::as_str) else {
if object.contains_key("enum") {
return Ok(());
}
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated value Schemas need a supported type or string enum".to_owned(),
});
};
match schema_type {
"object" => {
let properties = object
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut rust_names = BTreeSet::new();
let mut nested_type_names = BTreeSet::new();
for (name, property) in &properties {
if !rust_names.insert(rust_field_name(name)) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!(
"object properties collide after Rust field normalization near `{name}`"
),
});
}
if !nested_type_names.insert(pascal_case(name)) {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!(
"object properties collide after nested type-name generation near `{name}`"
),
});
}
validate_value_generation_schema(property, source_path)?;
}
match object.get("additionalProperties") {
Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) | None if !properties.is_empty() => {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "open objects with declared properties would lose unknown fields"
.to_owned(),
});
}
Some(additional) if !additional.is_boolean() && !properties.is_empty() => {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "objects cannot combine declared properties with a map payload"
.to_owned(),
});
}
Some(additional) if !additional.is_boolean() => {
validate_value_generation_schema(additional, source_path)?;
}
_ => {}
}
}
"array" => {
let items = object
.get("items")
.ok_or_else(|| CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "generated array Schemas must declare items".to_owned(),
})?;
validate_value_generation_schema(items, source_path)?;
}
"string" | "integer" | "number" | "boolean" | "null" => {}
_ => {
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("Schema type `{schema_type}` cannot be generated"),
});
}
}
Ok(())
}
fn validate_domain_error_schema(schema: &Value, source_path: &Path) -> Result<(), CodegenError> {
if schema
.as_object()
.is_some_and(|object| object.contains_key("allOf"))
{
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Domain Error Schemas do not support allOf unions".to_owned(),
});
}
let variants = schema
.get("oneOf")
.and_then(Value::as_array)
.filter(|variants| !variants.is_empty())
.ok_or_else(|| CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Domain Error Schemas must be a non-empty oneOf union".to_owned(),
})?;
let mut codes = BTreeSet::new();
let mut names = BTreeSet::from(["Unknown".to_owned()]);
for variant in variants {
if variant
.as_object()
.is_some_and(|object| object.contains_key("allOf"))
{
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Domain Error variants do not support allOf unions".to_owned(),
});
}
if let Some(code) = variant.get("const").and_then(Value::as_str) {
let name = pascal_case(code);
if !codes.insert(code.to_owned())
|| !names.insert(name.clone())
|| !is_generated_enum_variant(&name)
{
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("Domain Error code `{code}` is duplicated or name-colliding"),
});
}
continue;
}
let object = variant
.as_object()
.ok_or_else(|| CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "Domain Error variants must be string consts or objects".to_owned(),
})?;
let properties = object
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "structured Domain Error variants need properties".to_owned(),
})?;
if object.get("additionalProperties") != Some(&Value::Bool(false))
|| !required_fields(variant).contains("code")
|| properties
.keys()
.any(|name| name != "code" && name != "payload")
{
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail:
"structured Domain Error objects must explicitly allow only code and payload"
.to_owned(),
});
}
let code = object
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("code"))
.and_then(|code| code.get("const"))
.and_then(Value::as_str)
.ok_or_else(|| CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: "structured Domain Error variants need a const string code".to_owned(),
})?;
let name = pascal_case(code);
if !codes.insert(code.to_owned())
|| !names.insert(name.clone())
|| !is_generated_enum_variant(&name)
{
return Err(CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: format!("Domain Error code `{code}` is duplicated or name-colliding"),
});
}
if let Some(payload) = object
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("payload"))
{
validate_value_generation_schema(payload, source_path)?;
}
}
Ok(())
}
/// Generates both language artifacts from one Descriptor source.
pub fn generate(path: &Path) -> Result<GeneratedArtifacts, CodegenError> {
let descriptor = load_descriptor(path)?;
let contract = contract_ir(&descriptor);
let metadata = GeneratedMetadata {
capability_id: descriptor.capability_id.clone(),
descriptor_version: descriptor.version.clone(),
portable: descriptor.portable,
cross_lane_transfer: descriptor.cross_lane_transfer,
};
Ok(GeneratedArtifacts {
metadata,
rust: generate_rust(&contract),
typescript: generate_typescript(&contract),
})
}
/// Writes generated artifacts to checked-in paths.
pub fn write_generated(
descriptor_path: &Path,
rust_path: &Path,
typescript_path: &Path,
) -> Result<(), CodegenError> {
let artifacts = generate(descriptor_path)?;
write_artifact(rust_path, &artifacts.rust)?;
write_artifact(typescript_path, &artifacts.typescript)?;
Ok(())
}
/// Fails when either checked-in artifact is not exactly reproducible.
pub fn check_generated(
descriptor_path: &Path,
rust_path: &Path,
typescript_path: &Path,
) -> Result<(), CodegenError> {
let artifacts = generate(descriptor_path)?;
check_artifact(rust_path, &artifacts.rust)?;
check_artifact(typescript_path, &artifacts.typescript)?;
Ok(())
}
/// Round-trips one JSON value through the portable wire representation.
///
/// Raw JSON numbers are restricted to JavaScript's safe integer range. Wide
/// integers must therefore be represented by the Descriptor's `int64` or
/// `uint64` decimal-string formats, which this function preserves exactly.
pub fn round_trip_portable_json(value: &Value) -> Result<Value, CodegenError> {
validate_portable_value(value, "$".to_owned())?;
let encoded = serde_json::to_vec(value).expect("JSON values are serializable");
serde_json::from_slice(&encoded).map_err(|error| CodegenError::InvalidPortableValue {
path: "$".to_owned(),
detail: format!("wire JSON could not be decoded: {error}"),
})
}
/// Validates a wire value against a package-local JSON Schema.
///
/// This is intentionally a small validator for the Descriptor's portable
/// profile rather than a general JSON Schema implementation. It makes the
/// profile's loss-sensitive mappings executable: wide integers must use their
/// decimal-string formats, bytes must be canonical base64, and timestamp and
/// duration strings must have their documented wire shapes.
pub fn validate_wire_value(schema_path: &Path, value: &Value) -> Result<(), CodegenError> {
let schema_path = canonical_path(schema_path)?;
let package_root = schema_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let source = read_json(&schema_path)?;
let mut ref_stack = Vec::new();
let schema = resolve_refs(
&source,
&source,
&schema_path,
&package_root,
&mut ref_stack,
)?;
validate_schema_profile(&schema, &schema_path)?;
validate_wire_value_inner(&schema, value, "$", &schema_path)
}
fn validate_wire_value_inner(
schema: &Value,
value: &Value,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
if let Some(constant) = schema.get("const")
&& value != constant
{
return invalid_wire_value(path, "value does not match the Schema const", source_path);
}
if let Some(values) = schema.get("enum").and_then(Value::as_array)
&& !values.iter().any(|candidate| candidate == value)
{
return invalid_wire_value(
path,
"value is not included in the Schema enum",
source_path,
);
}
if let Some(alternatives) = schema.get("oneOf").and_then(Value::as_array) {
let matches = alternatives
.iter()
.filter(|alternative| {
validate_wire_value_inner(alternative, value, path, source_path).is_ok()
})
.count();
if matches == 0
&& is_open_domain_error_schema(schema)
&& is_unknown_domain_error_wire(schema, value)
{
return validate_portable_value(value, path.to_owned());
}
if matches != 1 {
return invalid_wire_value(
path,
"value must match exactly one Schema oneOf alternative",
source_path,
);
}
}
if let Some(alternatives) = schema.get("anyOf").and_then(Value::as_array)
&& !alternatives.iter().any(|alternative| {
validate_wire_value_inner(alternative, value, path, source_path).is_ok()
})
{
return invalid_wire_value(
path,
"value must match at least one Schema anyOf alternative",
source_path,
);
}
match schema.get("type") {
Some(Value::String(schema_type)) => {
validate_wire_type(schema, schema_type, value, path, source_path)
}
Some(Value::Array(types)) => {
let mut errors = Vec::new();
for schema_type in types {
let Some(schema_type) = schema_type.as_str() else {
continue;
};
match validate_wire_type(schema, schema_type, value, path, source_path) {
Ok(()) => return Ok(()),
Err(error) => errors.push(error.to_string()),
}
}
invalid_wire_value(
path,
&format!(
"value does not match any Schema type ({})",
errors.join("; ")
),
source_path,
)
}
Some(_) => invalid_wire_value(path, "Schema type must be a string or array", source_path),
None if schema
.as_object()
.is_some_and(|object| object.contains_key("const") || object.contains_key("enum")) =>
{
Ok(())
}
None => Ok(()),
}
}
fn is_open_domain_error_schema(schema: &Value) -> bool {
schema
.get("oneOf")
.and_then(Value::as_array)
.is_some_and(|variants| {
!variants.is_empty()
&& variants.iter().all(|variant| {
variant.get("const").and_then(Value::as_str).is_some()
|| variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("code"))
.and_then(|code| code.get("const"))
.and_then(Value::as_str)
.is_some()
})
})
}
fn is_unknown_domain_error_wire(schema: &Value, value: &Value) -> bool {
let known_codes = schema
.get("oneOf")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|variant| {
variant.get("const").and_then(Value::as_str).or_else(|| {
variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("code"))
.and_then(|code| code.get("const"))
.and_then(Value::as_str)
})
})
.collect::<BTreeSet<_>>();
match value {
Value::String(code) => !known_codes.contains(code.as_str()),
Value::Object(object) => object
.get("code")
.and_then(Value::as_str)
.is_some_and(|code| !known_codes.contains(code)),
_ => false,
}
}
fn validate_wire_type(
schema: &Value,
schema_type: &str,
value: &Value,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
match schema_type {
"object" => validate_wire_object(schema, value, path, source_path),
"array" => validate_wire_array(schema, value, path, source_path),
"string" => validate_wire_string(schema, value, path, source_path),
"integer" => {
if !value.is_number()
|| value.as_i64().is_none() && value.as_u64().is_none()
|| !is_safe_json_integer(value)
{
return invalid_wire_value(path, "expected an integer", source_path);
}
validate_numeric_constraint(schema, value, path, source_path)
}
"number" => {
if value
.as_number()
.is_none_or(|number| !is_safe_json_number(number))
{
return invalid_wire_value(path, "expected a finite number", source_path);
}
validate_numeric_constraint(schema, value, path, source_path)
}
"boolean" => {
if !value.is_boolean() {
return invalid_wire_value(path, "expected a boolean", source_path);
}
Ok(())
}
"null" => {
if !value.is_null() {
return invalid_wire_value(path, "expected null", source_path);
}
Ok(())
}
_ => invalid_wire_value(
path,
"Schema type is outside the portable profile",
source_path,
),
}
}
fn is_safe_json_integer(value: &Value) -> bool {
value
.as_i64()
.is_some_and(|value| (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value))
|| value
.as_u64()
.is_some_and(|value| value <= MAX_SAFE_INTEGER as u64)
}
fn is_safe_json_number(number: &serde_json::Number) -> bool {
number
.as_i64()
.is_some_and(|value| (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value))
|| number
.as_u64()
.is_some_and(|value| value <= MAX_SAFE_INTEGER as u64)
|| number.as_f64().is_some_and(|value| {
value.is_finite() && (value.abs() <= 9_007_199_254_740_991.0 || value.fract() != 0.0)
})
}
fn validate_wire_object(
schema: &Value,
value: &Value,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
let Value::Object(properties) = value else {
return invalid_wire_value(path, "expected an object", source_path);
};
let required = required_fields(schema);
for field in required {
if !properties.contains_key(&field) {
return invalid_wire_value(
&format!("{path}.{field}"),
"required field is missing",
source_path,
);
}
}
let declared = schema
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (field, field_value) in properties {
if let Some(field_schema) = declared.get(field) {
validate_wire_value_inner(
field_schema,
field_value,
&format!("{path}.{field}"),
source_path,
)?;
continue;
}
match schema.get("additionalProperties") {
Some(Value::Bool(false)) => {
return invalid_wire_value(
&format!("{path}.{field}"),
"additional property is not allowed",
source_path,
);
}
Some(additional_schema) if !additional_schema.is_boolean() => {
validate_wire_value_inner(
additional_schema,
field_value,
&format!("{path}.{field}"),
source_path,
)?;
}
_ => {}
}
}
Ok(())
}
fn validate_wire_array(
schema: &Value,
value: &Value,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
let Value::Array(values) = value else {
return invalid_wire_value(path, "expected an array", source_path);
};
if let Some(items) = schema.get("items") {
for (index, item) in values.iter().enumerate() {
validate_wire_value_inner(items, item, &format!("{path}[{index}]"), source_path)?;
}
}
let length = u64::try_from(values.len()).unwrap_or(u64::MAX);
if let Some(minimum) = schema.get("minItems").and_then(Value::as_u64)
&& length < minimum
{
return invalid_wire_value(path, "array has fewer items than minItems", source_path);
}
if let Some(maximum) = schema.get("maxItems").and_then(Value::as_u64)
&& length > maximum
{
return invalid_wire_value(path, "array has more items than maxItems", source_path);
}
Ok(())
}
fn validate_wire_string(
schema: &Value,
value: &Value,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
let Value::String(value) = value else {
return invalid_wire_value(path, "expected a string", source_path);
};
validate_string_format(schema, value, path, source_path)?;
let length = u64::try_from(value.chars().count()).unwrap_or(u64::MAX);
if let Some(minimum) = schema.get("minLength").and_then(Value::as_u64)
&& length < minimum
{
return invalid_wire_value(path, "string is shorter than minLength", source_path);
}
if let Some(maximum) = schema.get("maxLength").and_then(Value::as_u64)
&& length > maximum
{
return invalid_wire_value(path, "string is longer than maxLength", source_path);
}
Ok(())
}
fn validate_numeric_constraint(
schema: &Value,
value: &Value,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
let Some(number) = value.as_f64() else {
return Ok(());
};
if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64)
&& number < minimum
{
return invalid_wire_value(path, "number is below minimum", source_path);
}
if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64)
&& number > maximum
{
return invalid_wire_value(path, "number is above maximum", source_path);
}
if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_f64)
&& number <= minimum
{
return invalid_wire_value(path, "number is not above exclusiveMinimum", source_path);
}
if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_f64)
&& number >= maximum
{
return invalid_wire_value(path, "number is not below exclusiveMaximum", source_path);
}
Ok(())
}
fn validate_string_format(
schema: &Value,
value: &str,
path: &str,
source_path: &Path,
) -> Result<(), CodegenError> {
let Some(format) = schema.get("format").and_then(Value::as_str) else {
return Ok(());
};
let valid = match format {
"int64" => is_signed_decimal(value),
"uint64" => is_unsigned_decimal(value),
"byte" => is_base64(value),
"date-time" => is_rfc3339(value),
"duration" => is_iso8601_duration(value),
_ => false,
};
if valid {
Ok(())
} else {
invalid_wire_value(
path,
&format!("string does not match portable `{format}` format"),
source_path,
)
}
}
fn invalid_wire_value<T>(path: &str, detail: &str, _source_path: &Path) -> Result<T, CodegenError> {
Err(CodegenError::InvalidPortableValue {
path: path.to_owned(),
detail: detail.to_owned(),
})
}
fn is_signed_decimal(value: &str) -> bool {
let digits = value.strip_prefix('-').unwrap_or(value);
!digits.is_empty()
&& (digits == "0" || !digits.starts_with('0'))
&& digits.chars().all(|character| character.is_ascii_digit())
&& value.parse::<i64>().is_ok()
}
fn is_unsigned_decimal(value: &str) -> bool {
!value.is_empty()
&& (value == "0" || !value.starts_with('0'))
&& value.chars().all(|character| character.is_ascii_digit())
&& value.parse::<u64>().is_ok()
}
fn is_base64(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.is_empty() {
return true;
}
if !bytes.len().is_multiple_of(4) {
return false;
}
let padding = bytes.iter().rev().take_while(|byte| **byte == b'=').count();
if padding > 2 || bytes[..bytes.len() - padding].contains(&b'=') {
return false;
}
for byte in &bytes[..bytes.len() - padding] {
if base64_digit(*byte).is_none() {
return false;
}
}
let last = &bytes[bytes.len() - 4..];
if padding == 1 {
base64_digit(last[2]).is_some_and(|digit| digit.trailing_zeros() >= 2)
} else if padding == 2 {
base64_digit(last[1]).is_some_and(|digit| digit.trailing_zeros() >= 4)
} else {
base64_digit(last[2]).is_some() && base64_digit(last[3]).is_some()
}
}
fn base64_digit(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
fn is_rfc3339(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() < 20
|| !matches!(bytes.get(4), Some(b'-'))
|| !matches!(bytes.get(7), Some(b'-'))
|| !matches!(bytes.get(10), Some(b'T' | b't'))
|| !matches!(bytes.get(13), Some(b':'))
|| !matches!(bytes.get(16), Some(b':'))
{
return false;
}
let Some(month) = fixed_digits(bytes, 5, 2) else {
return false;
};
let Some(day) = fixed_digits(bytes, 8, 2) else {
return false;
};
let Some(hour) = fixed_digits(bytes, 11, 2) else {
return false;
};
let Some(minute) = fixed_digits(bytes, 14, 2) else {
return false;
};
let Some(second) = fixed_digits(bytes, 17, 2) else {
return false;
};
let Some(year) = fixed_digits(bytes, 0, 4) else {
return false;
};
let max_day = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
2 => 28,
_ => return false,
};
if day == 0 || day > max_day || hour > 23 || minute > 59 || second > 60 {
return false;
}
let mut index = 19;
if bytes.get(index) == Some(&b'.') {
index += 1;
let start = index;
while bytes.get(index).is_some_and(u8::is_ascii_digit) {
index += 1;
}
if index == start {
return false;
}
}
match bytes.get(index) {
Some(b'Z' | b'z') => index + 1 == bytes.len(),
Some(b'+' | b'-') => {
index += 1;
let Some(offset_hour) = fixed_digits(bytes, index, 2) else {
return false;
};
index += 2;
if bytes.get(index) != Some(&b':') {
return false;
}
index += 1;
let Some(offset_minute) = fixed_digits(bytes, index, 2) else {
return false;
};
offset_hour <= 23 && offset_minute <= 59 && index + 2 == bytes.len()
}
_ => false,
}
}
fn fixed_digits(bytes: &[u8], start: usize, length: usize) -> Option<u32> {
let slice = bytes.get(start..start + length)?;
slice.iter().try_fold(0_u32, |value, digit| {
digit
.is_ascii_digit()
.then(|| value * 10 + u32::from(digit - b'0'))
})
}
fn is_iso8601_duration(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = usize::from(bytes.first() == Some(&b'-'));
if bytes.get(index) != Some(&b'P') {
return false;
}
index += 1;
let mut in_time = false;
let mut saw_component = false;
let mut saw_time = false;
while index < bytes.len() {
if bytes[index] == b'T' {
if in_time || index + 1 == bytes.len() {
return false;
}
in_time = true;
index += 1;
continue;
}
let start = index;
let mut separator_seen = false;
while index < bytes.len()
&& (bytes[index].is_ascii_digit() || (!separator_seen && bytes[index] == b'.'))
{
separator_seen |= bytes[index] == b'.';
index += 1;
}
if index == start || bytes.get(index).is_none() {
return false;
}
let unit = bytes[index];
let valid_unit = if in_time {
matches!(unit, b'H' | b'M' | b'S')
} else {
matches!(unit, b'Y' | b'M' | b'W' | b'D')
};
if !valid_unit {
return false;
}
if in_time {
saw_time = true;
}
saw_component = true;
index += 1;
}
saw_component && (!in_time || saw_time)
}
fn validate_portable_value(value: &Value, path: String) -> Result<(), CodegenError> {
match value {
Value::Number(number) => {
if !is_safe_json_number(number) {
return Err(CodegenError::InvalidPortableValue {
path,
detail: "wide integers must use an explicit decimal-string format".to_owned(),
});
}
}
Value::Array(values) => {
for (index, value) in values.iter().enumerate() {
validate_portable_value(value, format!("{path}[{index}]"))?;
}
}
Value::Object(values) => {
for (key, value) in values {
validate_portable_value(value, format!("{path}.{key}"))?;
}
}
Value::Null | Value::Bool(_) | Value::String(_) => {}
}
Ok(())
}
fn write_artifact(path: &Path, contents: &str) -> Result<(), CodegenError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| CodegenError::Io {
path: parent.to_path_buf(),
source,
})?;
}
fs::write(path, contents).map_err(|source| CodegenError::Io {
path: path.to_path_buf(),
source,
})
}
fn check_artifact(path: &Path, expected: &str) -> Result<(), CodegenError> {
let actual = fs::read_to_string(path).map_err(|source| CodegenError::Io {
path: path.to_path_buf(),
source,
})?;
if actual == expected {
Ok(())
} else {
Err(CodegenError::GeneratedArtifactDrift {
path: path.to_path_buf(),
})
}
}
/// Checks whether `new_path` is an additive, compatible evolution of
/// `old_path`. `true` means the same Capability series remains compatible.
pub fn lint_compatibility(old_path: &Path, new_path: &Path) -> Result<bool, CompatibilityError> {
let old = load_descriptor(old_path).map_err(|error| CompatibilityError::BreakingChanges {
changes: vec![error.to_string()],
})?;
let new = load_descriptor(new_path).map_err(|error| CompatibilityError::BreakingChanges {
changes: vec![error.to_string()],
})?;
if old.capability_id != new.capability_id {
return Err(CompatibilityError::IdentityChanged {
from: old.capability_id,
to: new.capability_id,
});
}
if new.parsed_version <= old.parsed_version {
return Err(CompatibilityError::VersionNotAdvanced {
from: old.version,
to: new.version,
});
}
if new.parsed_version.major() != old.parsed_version.major() {
return Err(CompatibilityError::BreakingChanges {
changes: vec!["a breaking change must use a new Capability @major identity".to_owned()],
});
}
let mut changes = Vec::new();
if old.portable != new.portable {
changes.push("Descriptor portability changed".to_owned());
}
if old.cross_lane_transfer && !new.cross_lane_transfer {
changes.push("Descriptor cross-lane transfer support was removed".to_owned());
}
let old_operations: BTreeMap<_, _> = old
.operations
.iter()
.map(|operation| (operation.name.as_str(), operation))
.collect();
let new_operations: BTreeMap<_, _> = new
.operations
.iter()
.map(|operation| (operation.name.as_str(), operation))
.collect();
for (name, old_operation) in &old_operations {
let Some(new_operation) = new_operations.get(name) else {
changes.push(format!("Operation `{name}` was removed"));
continue;
};
if old_operation.interaction != new_operation.interaction {
changes.push(format!("Operation `{name}` changed interaction"));
}
compare_schema(
&old_operation.request_schema,
&new_operation.request_schema,
&format!("Operation `{name}` request"),
&mut changes,
);
compare_schema(
&old_operation.response_schema,
&new_operation.response_schema,
&format!("Operation `{name}` response"),
&mut changes,
);
compare_error_schema(
&old_operation.domain_error_schema,
&new_operation.domain_error_schema,
&format!("Operation `{name}` Domain Error"),
&mut changes,
);
}
if new.parsed_version.minor() == old.parsed_version.minor() {
if contract_signature(&old) != contract_signature(&new) {
changes.push("a patch release changed the observable contract".to_owned());
}
for name in new_operations
.keys()
.filter(|name| !old_operations.contains_key(**name))
{
changes.push(format!("Operation `{name}` was added in a patch release"));
}
}
if new.parsed_version.minor() < old.parsed_version.minor() {
changes.push("Descriptor minor version moved backwards".to_owned());
}
if changes.is_empty() {
Ok(true)
} else {
Err(CompatibilityError::BreakingChanges { changes })
}
}
fn contract_signature(descriptor: &Descriptor) -> String {
let operations = descriptor
.operations
.iter()
.map(|operation| {
serde_json::json!({
"name": operation.name,
"interaction": operation.interaction,
"request": operation.request_schema,
"response": operation.response_schema,
"domain_error": canonical_domain_error_schema(&operation.domain_error_schema),
})
})
.collect::<Vec<_>>();
canonical_json(&Value::Array(operations))
}
fn canonical_domain_error_schema(schema: &Value) -> Value {
let Some(variants) = schema.get("oneOf").and_then(Value::as_array) else {
return schema.clone();
};
let mut variants = variants.clone();
variants.sort_by(|left, right| {
error_variant_code(left)
.unwrap_or_default()
.cmp(error_variant_code(right).unwrap_or_default())
});
let mut schema = schema.as_object().cloned().unwrap_or_default();
schema.insert("oneOf".to_owned(), Value::Array(variants));
Value::Object(schema)
}
fn error_variant_code(variant: &Value) -> Option<&str> {
variant.get("const").and_then(Value::as_str).or_else(|| {
variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("code"))
.and_then(|code| code.get("const"))
.and_then(Value::as_str)
})
}
fn compare_schema(old: &Value, new: &Value, location: &str, changes: &mut Vec<String>) {
let old_nullable = is_nullable(old);
let new_nullable = is_nullable(new);
if old_nullable != new_nullable {
changes.push(format!("{location} changed nullability"));
return;
}
let old_type = schema_type_name(old);
let new_type = schema_type_name(new);
if old_type != new_type {
changes.push(format!("{location} changed type"));
return;
}
match old_type {
Some("object") => {
let old_properties = old.get("properties").and_then(Value::as_object);
let new_properties = new.get("properties").and_then(Value::as_object);
let (Some(old_properties), Some(new_properties)) = (old_properties, new_properties)
else {
if canonical_json(old) != canonical_json(new) {
changes.push(format!("{location} changed object shape"));
}
return;
};
let old_required = required_fields(old);
let new_required = required_fields(new);
for name in old_properties.keys() {
let Some(new_schema) = new_properties.get(name) else {
changes.push(format!("{location} field `{name}` was removed"));
continue;
};
compare_schema(
&old_properties[name],
new_schema,
&format!("{location} field `{name}`"),
changes,
);
}
for name in new_required.difference(&old_required) {
changes.push(format!("{location} field `{name}` became required"));
}
for name in old_required.difference(&new_required) {
changes.push(format!("{location} field `{name}` became optional"));
}
if old.get("additionalProperties") != new.get("additionalProperties") {
changes.push(format!("{location} changed additional-properties policy"));
}
if schema_constraints_signature(old) != schema_constraints_signature(new) {
changes.push(format!("{location} changed constraints"));
}
}
Some("array") => {
if let (Some(old_items), Some(new_items)) = (old.get("items"), new.get("items")) {
compare_schema(old_items, new_items, &format!("{location} items"), changes);
}
if schema_constraints_signature(old) != schema_constraints_signature(new) {
changes.push(format!("{location} changed constraints"));
}
}
_ => {
if canonical_json(old) != canonical_json(new) {
changes.push(format!("{location} changed format or constraints"));
}
}
}
}
fn compare_error_schema(old: &Value, new: &Value, location: &str, changes: &mut Vec<String>) {
let old_variants = error_variant_values(old);
let new_variants = error_variant_values(new);
if old_variants.is_empty() || new_variants.is_empty() {
if canonical_json(old) != canonical_json(new) {
changes.push(format!("{location} changed shape"));
}
return;
}
for (code, schema) in old_variants {
match new_variants.get(&code) {
None => changes.push(format!("{location} variant `{code}` was removed")),
Some(new_schema) => compare_schema(
&schema,
new_schema,
&format!("{location} variant `{code}`"),
changes,
),
}
}
}
fn error_variant_ir_definitions(schema: &Value) -> Vec<ErrorVariantIr> {
let mut variants = schema
.get("oneOf")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|variant| {
if let Some(code) = variant.get("const").and_then(Value::as_str) {
return Some(ErrorVariantIr {
code: code.to_owned(),
name: pascal_case(code),
structured: false,
payload: None,
payload_required: false,
});
}
let object = variant.as_object()?;
let code = object
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("code"))
.and_then(|code| code.get("const"))
.and_then(Value::as_str)?;
Some(ErrorVariantIr {
code: code.to_owned(),
name: pascal_case(code),
structured: true,
payload: object
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("payload"))
.map(type_ir_from_schema),
payload_required: required_fields(variant).contains("payload"),
})
})
.collect::<Vec<_>>();
variants.sort_by(|left, right| left.code.cmp(&right.code));
variants
}
fn error_variant_values(schema: &Value) -> BTreeMap<String, Value> {
let variants = schema
.get("oneOf")
.and_then(Value::as_array)
.into_iter()
.flatten();
variants
.filter_map(|variant| {
let code = if let Some(code) = variant.get("const").and_then(Value::as_str) {
code.to_owned()
} else {
variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("code"))
.and_then(|code| code.get("const"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)?
};
Some((code, variant.clone()))
})
.collect()
}
fn required_fields(schema: &Value) -> BTreeSet<String> {
schema
.get("required")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
}
fn is_nullable(schema: &Value) -> bool {
schema
.get("type")
.and_then(Value::as_array)
.is_some_and(|types| types.iter().any(|value| value == "null"))
|| schema
.get("anyOf")
.and_then(Value::as_array)
.is_some_and(|schemas| schemas.iter().any(is_null_schema))
|| schema
.get("oneOf")
.and_then(Value::as_array)
.is_some_and(|schemas| schemas.iter().any(is_null_schema))
}
fn is_null_schema(schema: &Value) -> bool {
schema.get("type").is_some_and(|value| value == "null")
|| schema.get("const").is_some_and(Value::is_null)
}
fn schema_type_name(schema: &Value) -> Option<&str> {
schema.get("type").and_then(|value| match value {
Value::String(value) => Some(value.as_str()),
Value::Array(values) => values
.iter()
.find_map(Value::as_str)
.filter(|value| *value != "null"),
_ => None,
})
}
fn canonical_json(value: &Value) -> String {
serde_json::to_string(value).expect("JSON values are serializable")
}
fn schema_constraints_signature(schema: &Value) -> String {
let mut schema = schema.as_object().cloned().unwrap_or_default();
for key in [
"type",
"properties",
"required",
"additionalProperties",
"items",
] {
schema.remove(key);
}
canonical_json(&Value::Object(schema))
}
fn is_identifier(value: &str) -> bool {
let mut characters = value.chars();
characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
&& characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
}
fn is_rust_member_name(value: &str) -> bool {
!matches!(snake_case(value).as_str(), "self" | "super" | "crate")
}
fn is_rust_type_identifier(value: &str) -> bool {
let mut characters = value.chars();
let valid = characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
&& characters.all(|character| character.is_ascii_alphanumeric() || character == '_');
valid && value != "Self"
}
fn is_generated_type_name(value: &str) -> bool {
is_rust_type_identifier(value)
&& !matches!(
value,
"Self"
| "String"
| "Int64"
| "Uint64"
| "Bytes"
| "Timestamp"
| "Duration"
| "OptionalValue"
| "UnknownDomainError"
| "InvocationContext"
| "ModuleDependencies"
| "NativeRequestEndpoint"
| "NativeRequestHandle"
| "RequestCapability"
| "RuntimeFailure"
| "LocalBoxFuture"
)
}
fn is_generated_enum_variant(value: &str) -> bool {
is_rust_type_identifier(value)
}
fn pascal_case(value: &str) -> String {
let mut output = String::new();
for part in value.split(|character: char| !character.is_ascii_alphanumeric()) {
if part.is_empty() || part.chars().all(char::is_numeric) {
continue;
}
let mut chars = part.chars();
if let Some(first) = chars.next() {
output.extend(first.to_uppercase());
output.push_str(chars.as_str());
}
}
if output.is_empty() {
"Value".to_owned()
} else if output
.chars()
.next()
.is_some_and(|character| character.is_ascii_digit())
{
format!("Value{output}")
} else {
output
}
}
fn snake_case(value: &str) -> String {
let mut output = String::new();
for (index, character) in value.chars().enumerate() {
if character.is_ascii_alphanumeric() {
if character.is_ascii_uppercase() && index != 0 {
output.push('_');
}
output.push(character.to_ascii_lowercase());
} else if !output.ends_with('_') {
output.push('_');
}
}
let output = output.trim_matches('_').to_owned();
if output.is_empty() {
"value".to_owned()
} else {
output
}
}
fn screaming_snake_case(value: &str) -> String {
snake_case(value).to_ascii_uppercase()
}
fn rust_field_name(value: &str) -> String {
let name = snake_case(value);
if matches!(name.as_str(), "self" | "super" | "crate") {
return format!("{name}_");
}
if matches!(
name.as_str(),
"as" | "break"
| "const"
| "continue"
| "crate"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "move"
| "mut"
| "pub"
| "ref"
| "return"
| "self"
| "Self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "unsafe"
| "use"
| "where"
| "while"
| "async"
| "await"
| "dyn"
) {
format!("r#{name}")
} else {
name
}
}
fn quote_string(value: &str) -> String {
serde_json::to_string(value).expect("string is serializable")
}
fn typescript_property_name(value: &str) -> String {
let mut characters = value.chars();
let valid_identifier = characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | '$'))
&& characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'));
if valid_identifier {
value.to_owned()
} else {
quote_string(value)
}
}
struct RustTypes {
declarations: Vec<String>,
declared: BTreeSet<String>,
}
impl RustTypes {
fn new() -> Self {
Self {
declarations: Vec::new(),
declared: BTreeSet::new(),
}
}
fn object(&mut self, name: &str, fields: &[FieldIr]) -> String {
if !self.declared.insert(name.to_owned()) {
return name.to_owned();
}
let placeholder = self.declarations.len();
self.declarations.push(String::new());
let mut rendered_fields = Vec::new();
for field in fields {
let type_name = self.type_for_non_null(
field.ty.non_null(),
&format!("{name}{}", pascal_case(&field.name)),
);
let field_type = if field.required {
if field.ty.is_nullable() {
format!("Option<{type_name}>")
} else {
type_name
}
} else if field.ty.is_nullable() {
format!("OptionalValue<{type_name}>")
} else {
format!("Option<{type_name}>")
};
let mut attributes = vec![format!(
" #[serde(rename = {})]",
quote_string(&field.name)
)];
if !field.required {
if field.ty.is_nullable() {
attributes.push(" #[serde(default)]".to_owned());
attributes
.push(" #[serde(skip_serializing_if = \"Option::is_none\")]".to_owned());
attributes.push(
" #[serde(deserialize_with = \"deserialize_optional_value\")]"
.to_owned(),
);
} else {
attributes
.push(" #[serde(skip_serializing_if = \"Option::is_none\")]".to_owned());
}
}
if field.required {
attributes
.push(" #[serde(deserialize_with = \"deserialize_required\")]".to_owned());
}
rendered_fields.push(format!(
"{}\n pub {}: {field_type},",
attributes.join("\n"),
rust_field_name(&field.name)
));
}
let debug_impl = rust_debug::render(
name,
fields.iter().map(|field| {
(
field.name.as_str(),
rust_field_name(&field.name),
field.sensitive,
)
}),
);
let derives = if debug_impl.is_some() {
"Clone, PartialEq, serde::Serialize, serde::Deserialize"
} else {
"Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize"
};
self.declarations[placeholder] = format!(
"#[derive({derives})]\npub struct {name} {{\n{}\n}}\n{debug_impl}",
rendered_fields.join("\n"),
debug_impl = debug_impl.unwrap_or_default(),
);
name.to_owned()
}
fn enum_type(&mut self, name: &str, values: &[String]) -> String {
if !self.declared.insert(name.to_owned()) {
return name.to_owned();
}
let variants = values
.iter()
.map(|value| {
format!(
" #[serde(rename = {})]\n {},",
quote_string(value),
pascal_case(value)
)
})
.collect::<Vec<_>>();
self.declarations.push(format!(
"#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]\npub enum {name} {{\n{}\n}}\n",
variants.join("\n")
));
name.to_owned()
}
fn type_for(&mut self, ty: &TypeIr, nested_name: &str) -> String {
let base = self.type_for_non_null(ty.non_null(), nested_name);
if ty.is_nullable() {
format!("Option<{base}>")
} else {
base
}
}
fn type_for_non_null(&mut self, ty: &TypeIr, nested_name: &str) -> String {
match ty {
TypeIr::Any => "serde_json::Value".to_owned(),
TypeIr::String => "String".to_owned(),
TypeIr::Enum(values) => self.enum_type(nested_name, values),
TypeIr::Int64 => "Int64".to_owned(),
TypeIr::Uint64 => "Uint64".to_owned(),
TypeIr::Bytes => "Bytes".to_owned(),
TypeIr::Timestamp => "Timestamp".to_owned(),
TypeIr::Duration => "Duration".to_owned(),
TypeIr::Integer => "i64".to_owned(),
TypeIr::Number => "f64".to_owned(),
TypeIr::Boolean => "bool".to_owned(),
TypeIr::Null => "()".to_owned(),
TypeIr::Array(items) => {
format!(
"Vec<{}>",
self.type_for(items, &format!("{nested_name}Item"))
)
}
TypeIr::Object { fields, additional } => {
if fields.is_empty() {
match additional {
ObjectAdditionalIr::Closed => self.object(nested_name, fields),
ObjectAdditionalIr::Any => {
"std::collections::BTreeMap<String, serde_json::Value>".to_owned()
}
ObjectAdditionalIr::Typed(values) => format!(
"std::collections::BTreeMap<String, {}>",
self.type_for(values, &format!("{nested_name}Value"))
),
}
} else {
self.object(nested_name, fields)
}
}
TypeIr::Nullable(inner) => {
format!("Option<{}>", self.type_for_non_null(inner, nested_name))
}
}
}
}
struct TypeScriptTypes {
declarations: Vec<String>,
declared: BTreeSet<String>,
}
impl TypeScriptTypes {
fn new() -> Self {
Self {
declarations: Vec::new(),
declared: BTreeSet::new(),
}
}
fn object(&mut self, name: &str, fields: &[FieldIr]) -> String {
if !self.declared.insert(name.to_owned()) {
return name.to_owned();
}
let placeholder = self.declarations.len();
self.declarations.push(String::new());
let mut rendered_fields = Vec::new();
for field in fields {
let type_name = self.type_for_non_null(
field.ty.non_null(),
&format!("{name}{}", pascal_case(&field.name)),
);
let optional = if field.required { "" } else { "?" };
let field_type = if field.ty.is_nullable() {
format!("{type_name} | null")
} else {
type_name
};
rendered_fields.push(format!(
" {}{optional}: {field_type};",
typescript_property_name(&field.name)
));
}
self.declarations[placeholder] = format!(
"export interface {name} {{\n{}\n}}\n",
rendered_fields.join("\n")
);
name.to_owned()
}
fn type_for(&mut self, ty: &TypeIr, nested_name: &str) -> String {
let base = self.type_for_non_null(ty.non_null(), nested_name);
if ty.is_nullable() {
format!("{base} | null")
} else {
base
}
}
fn type_for_non_null(&mut self, ty: &TypeIr, nested_name: &str) -> String {
match ty {
TypeIr::Any => "unknown".to_owned(),
TypeIr::String => "string".to_owned(),
TypeIr::Int64 => "Int64".to_owned(),
TypeIr::Uint64 => "Uint64".to_owned(),
TypeIr::Bytes => "Bytes".to_owned(),
TypeIr::Timestamp => "Timestamp".to_owned(),
TypeIr::Duration => "Duration".to_owned(),
TypeIr::Integer | TypeIr::Number => "number".to_owned(),
TypeIr::Boolean => "boolean".to_owned(),
TypeIr::Null => "null".to_owned(),
TypeIr::Enum(values) => values
.iter()
.map(|value| quote_string(value))
.collect::<Vec<_>>()
.join(" | "),
TypeIr::Array(items) => {
format!(
"Array<{}>",
self.type_for(items, &format!("{nested_name}Item"))
)
}
TypeIr::Object { fields, additional } => {
if fields.is_empty() {
match additional {
ObjectAdditionalIr::Closed => self.object(nested_name, fields),
ObjectAdditionalIr::Any => "Record<string, unknown>".to_owned(),
ObjectAdditionalIr::Typed(values) => format!(
"Record<string, {}>",
self.type_for(values, &format!("{nested_name}Value"))
),
}
} else {
self.object(nested_name, fields)
}
}
TypeIr::Nullable(inner) => {
format!("{} | null", self.type_for_non_null(inner, nested_name))
}
}
}
}
fn type_uses_bytes(value: &TypeIr) -> bool {
match value {
TypeIr::Bytes => true,
TypeIr::Array(items) | TypeIr::Nullable(items) => type_uses_bytes(items),
TypeIr::Object { fields, additional } => {
fields.iter().any(|field| type_uses_bytes(&field.ty))
|| matches!(additional, ObjectAdditionalIr::Typed(value) if type_uses_bytes(value))
}
TypeIr::Any
| TypeIr::String
| TypeIr::Int64
| TypeIr::Uint64
| TypeIr::Timestamp
| TypeIr::Duration
| TypeIr::Integer
| TypeIr::Number
| TypeIr::Boolean
| TypeIr::Null
| TypeIr::Enum(_) => false,
}
}
fn contract_uses_bytes(contract: &ContractIr) -> bool {
contract.operations.iter().any(|operation| {
type_uses_bytes(&operation.request)
|| type_uses_bytes(&operation.response)
|| operation
.domain_errors
.iter()
.filter_map(|error| error.payload.as_ref())
.any(type_uses_bytes)
})
}
const RUST_PORTABLE_TYPES: &str = r#"
pub type Int64 = String;
pub type Uint64 = String;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Bytes(Vec<u8>);
impl Bytes {
pub fn new(value: impl Into<Vec<u8>>) -> Self { Self(value.into()) }
pub fn as_slice(&self) -> &[u8] { &self.0 }
pub fn into_vec(self) -> Vec<u8> { self.0 }
}
impl From<Vec<u8>> for Bytes {
fn from(value: Vec<u8>) -> Self { Self(value) }
}
impl From<&[u8]> for Bytes {
fn from(value: &[u8]) -> Self { Self(value.to_vec()) }
}
impl From<Bytes> for Vec<u8> {
fn from(value: Bytes) -> Self { value.0 }
}
impl AsRef<[u8]> for Bytes {
fn as_ref(&self) -> &[u8] { self.as_slice() }
}
impl std::ops::Deref for Bytes {
type Target = [u8];
fn deref(&self) -> &Self::Target { self.as_slice() }
}
impl serde::Serialize for Bytes {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&encode_base64(self.as_slice()))
}
}
impl<'de> serde::Deserialize<'de> for Bytes {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let encoded = <String as serde::Deserialize>::deserialize(deserializer)?;
decode_base64(&encoded).map(Self).map_err(serde::de::Error::custom)
}
}
fn encode_base64(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity((input.len() + 2) / 3 * 4);
let mut chunks = input.chunks_exact(3);
for chunk in &mut chunks {
output.push(ALPHABET[usize::from(chunk[0] >> 2)] as char);
output.push(ALPHABET[usize::from((chunk[0] & 0x03) << 4 | chunk[1] >> 4)] as char);
output.push(ALPHABET[usize::from((chunk[1] & 0x0f) << 2 | chunk[2] >> 6)] as char);
output.push(ALPHABET[usize::from(chunk[2] & 0x3f)] as char);
}
match chunks.remainder() {
[first] => {
output.push(ALPHABET[usize::from(first >> 2)] as char);
output.push(ALPHABET[usize::from((first & 0x03) << 4)] as char);
output.push('=');
output.push('=');
}
[first, second] => {
output.push(ALPHABET[usize::from(first >> 2)] as char);
output.push(ALPHABET[usize::from((first & 0x03) << 4 | second >> 4)] as char);
output.push(ALPHABET[usize::from((second & 0x0f) << 2)] as char);
output.push('=');
}
[] => {}
_ => unreachable!("chunks_exact remainder is shorter than three bytes"),
}
output
}
fn decode_base64(input: &str) -> Result<Vec<u8>, &'static str> {
let input = input.as_bytes();
if input.len() % 4 != 0 {
return Err("bytes must be canonical padded base64");
}
let mut output = Vec::with_capacity(input.len() / 4 * 3);
let chunk_count = input.len() / 4;
for (index, chunk) in input.chunks_exact(4).enumerate() {
let last = index + 1 == chunk_count;
let first = base64_digit(chunk[0]).ok_or("bytes contain an invalid base64 digit")?;
let second = base64_digit(chunk[1]).ok_or("bytes contain an invalid base64 digit")?;
output.push(first << 2 | second >> 4);
match (chunk[2], chunk[3]) {
(b'=', b'=') if last && second & 0x0f == 0 => {}
(third, b'=') if last => {
let third = base64_digit(third).ok_or("bytes contain an invalid base64 digit")?;
if third & 0x03 != 0 {
return Err("bytes must be canonical padded base64");
}
output.push(second << 4 | third >> 2);
}
(third, fourth) if third != b'=' && fourth != b'=' => {
let third = base64_digit(third).ok_or("bytes contain an invalid base64 digit")?;
let fourth = base64_digit(fourth).ok_or("bytes contain an invalid base64 digit")?;
output.push(second << 4 | third >> 2);
output.push(third << 6 | fourth);
}
_ => return Err("bytes must be canonical padded base64"),
}
}
Ok(output)
}
fn base64_digit(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
pub type Timestamp = String;
pub type Duration = String;
pub type OptionalValue<T> = Option<Option<T>>;
"#;
#[allow(clippy::too_many_lines)]
fn generate_rust(contract: &ContractIr) -> String {
let capability_name = pascal_case(
contract
.capability_id
.split('@')
.next()
.and_then(|identity| identity.rsplit('.').next())
.unwrap_or("Capability"),
);
let capability_const = screaming_snake_case(&capability_name);
let mut types = RustTypes::new();
let mut operation_rows = Vec::new();
let mut stream_operation_rows = Vec::new();
let mut event_operation_rows = Vec::new();
let mut operation_markers = Vec::new();
let mut provider_methods = Vec::new();
let mut endpoint_arms = Vec::new();
let mut stream_endpoint_arms = Vec::new();
let mut event_endpoint_arms = Vec::new();
let mut client_fields = Vec::new();
let mut client_initializers = Vec::new();
let mut client_methods = Vec::new();
let mut invocation_errors = Vec::new();
let mut error_codecs = Vec::new();
let mut wire_codecs = Vec::new();
for operation in &contract.operations {
let operation_name = pascal_case(&operation.name);
let request_name = format!("{operation_name}Request");
let response_name = format!("{operation_name}Response");
let error_name = format!("{operation_name}Error");
let marker_name = if contract.operations.len() == 1 {
capability_name.clone()
} else {
format!("{capability_name}{operation_name}")
};
let request_type = types.type_for(&operation.request, &request_name);
let response_type = types.type_for(&operation.response, &response_name);
let known_errors = &operation.domain_errors;
let error_definition = if known_errors.is_empty() {
format!(
"#[derive(Clone, Debug, PartialEq)]\npub enum {error_name} {{\n Unknown(UnknownDomainError),\n}}\n"
)
} else {
let variants = known_errors
.iter()
.map(|variant| {
if let Some(payload) = &variant.payload {
let payload_name = format!("{error_name}{}Payload", variant.name);
let payload_type =
types.type_for_non_null(payload.non_null(), &payload_name);
let payload_type = if variant.payload_required {
if payload.is_nullable() {
format!("Option<{payload_type}>")
} else {
payload_type
}
} else if payload.is_nullable() {
format!("OptionalValue<{payload_type}>")
} else {
format!("Option<{payload_type}>")
};
format!(" {} {{ payload: {payload_type} }},", variant.name)
} else {
format!(" {},", variant.name)
}
})
.chain(std::iter::once(
" Unknown(UnknownDomainError),".to_owned(),
))
.collect::<Vec<_>>();
format!(
"#[derive(Clone, Debug, PartialEq)]\npub enum {error_name} {{\n{}\n}}\n",
variants.join("\n")
)
};
types.declarations.push(error_definition);
error_codecs.push(generate_rust_error_codec(&error_name, known_errors));
wire_codecs.push(generate_rust_wire_codecs(
&operation.name,
&request_type,
&response_type,
&error_name,
));
if operation.interaction == "event" {
wire_codecs.push(generate_rust_event_codecs(&operation.name, &request_type));
}
let operation_const = screaming_snake_case(&operation.name);
operation_markers.push(match operation.interaction.as_str() {
"request" => format!(
"#[derive(Debug)]\npub struct {marker_name};\nimpl RequestCapability for {marker_name} {{\n type Request = {request_type};\n type Response = {response_type};\n type DomainError = {error_name};\n const ID: &'static str = CAPABILITY_ID;\n const DESCRIPTOR_VERSION: &'static str = DESCRIPTOR_VERSION;\n}}\n"
),
"stream" => format!(
"#[derive(Debug)]\npub struct {marker_name};\npub type {marker_name}Event = StreamEvent<{response_type}, {error_name}>;\nimpl StreamCapability for {marker_name} {{\n type OpenRequest = {request_type};\n type Message = {response_type};\n type DomainError = {error_name};\n const ID: &'static str = CAPABILITY_ID;\n const DESCRIPTOR_VERSION: &'static str = DESCRIPTOR_VERSION;\n}}\n"
),
"event" => format!(
"#[derive(Debug)]\npub struct {marker_name};\nimpl EventCapability for {marker_name} {{\n type Event = {request_type};\n const ID: &'static str = CAPABILITY_ID;\n const DESCRIPTOR_VERSION: &'static str = DESCRIPTOR_VERSION;\n}}\n"
),
_ => unreachable!("Descriptor validation restricts interactions"),
});
if operation.interaction == "request" {
let invocation_error_name = if contract.operations.len() == 1 {
format!("{capability_name}InvocationError")
} else {
format!("{capability_name}{operation_name}InvocationError")
};
operation_rows.push(format!(" {operation_const}_OPERATION,\n"));
provider_methods.push(format!(
" fn {}(&self, context: InvocationContext, request: {request_type}) -> LocalBoxFuture<'static, Result<{response_type}, {invocation_error_name}>>;",
rust_field_name(&operation.name)
));
endpoint_arms.push(format!(
" {operation_const}_OPERATION => {{\n let Ok(request) = request.downcast::<{request_type}>() else {{\n return Box::pin(futures::future::ready(Err(RuntimeFailure::ProtocolViolation {{ capability: CAPABILITY_ID }})));\n }};\n let provider = Rc::clone(&self.provider);\n Box::pin(async move {{\n match provider.{}(context, *request).await {{\n Ok(value) => Ok(Ok(Box::new(value) as Box<dyn std::any::Any>)),\n Err({invocation_error_name}::Domain(error)) => Ok(Err(Box::new(error) as Box<dyn std::any::Any>)),\n Err({invocation_error_name}::Runtime(error)) => Err(error),\n }}\n }})\n }}",
rust_field_name(&operation.name),
));
let field = rust_field_name(&operation.name);
client_fields.push(format!(" {field}: NativeRequestHandle<{marker_name}>,"));
client_initializers.push(format!(
" {field}: dependencies.one::<{marker_name}>()?,"
));
client_methods.push(format!(
" pub async fn {field}(&self, request: {request_type}) -> Result<{response_type}, {invocation_error_name}> {{\n self.{field}.invoke({}_OPERATION, request).await\n .map_err({invocation_error_name}::Runtime)?\n .map_err({invocation_error_name}::Domain)\n }}\n\n pub async fn {field}_with_context(&self, context: InvocationContext, request: {request_type}) -> Result<{response_type}, {invocation_error_name}> {{\n self.{field}.invoke_with_context({}_OPERATION, context, request).await\n .map_err({invocation_error_name}::Runtime)?\n .map_err({invocation_error_name}::Domain)\n }}",
screaming_snake_case(&operation.name),
screaming_snake_case(&operation.name),
));
invocation_errors.push(format!(
"#[derive(Clone, Debug, PartialEq)]\npub enum {invocation_error_name} {{\n Domain({error_name}),\n Runtime(RuntimeFailure),\n}}\n"
));
} else if operation.interaction == "stream" {
let invocation_error_name = if contract.operations.len() == 1 {
format!("{capability_name}InvocationError")
} else {
format!("{capability_name}{operation_name}InvocationError")
};
stream_operation_rows.push(format!(" {operation_const}_OPERATION,\n"));
provider_methods.push(format!(
" fn {}(&self, context: InvocationContext, request: {request_type}) -> LocalBoxFuture<'static, Result<Box<dyn NativeStreamSession>, {invocation_error_name}>>;",
rust_field_name(&operation.name)
));
stream_endpoint_arms.push(format!(
" {operation_const}_OPERATION => {{\n let Ok(request) = request.downcast::<{request_type}>() else {{\n return Box::pin(futures::future::ready(Err(RuntimeFailure::ProtocolViolation {{ capability: CAPABILITY_ID }})));\n }};\n let provider = Rc::clone(&self.provider);\n Box::pin(async move {{\n match provider.{}(context, *request).await {{\n Ok(value) => Ok(Ok(value as Box<dyn NativeStreamSession>)),\n Err({invocation_error_name}::Domain(error)) => Ok(Err(Box::new(error) as Box<dyn std::any::Any>)),\n Err({invocation_error_name}::Runtime(error)) => Err(error),\n }}\n }})\n }}",
rust_field_name(&operation.name),
));
let field = rust_field_name(&operation.name);
client_fields.push(format!(" {field}: NativeStreamHandle<{marker_name}>,"));
client_initializers.push(format!(
" {field}: dependencies.one_stream::<{marker_name}>()?,"
));
client_methods.push(format!(
" pub async fn {field}(&self, request: {request_type}) -> Result<NativeStream<{marker_name}>, {invocation_error_name}> {{\n self.{field}.open({operation_const}_OPERATION, request).await\n .map_err({invocation_error_name}::Runtime)?\n .map_err({invocation_error_name}::Domain)\n }}\n\n pub async fn {field}_with_context(&self, context: InvocationContext, request: {request_type}) -> Result<NativeStream<{marker_name}>, {invocation_error_name}> {{\n self.{field}.open_with_context({operation_const}_OPERATION, context, request).await\n .map_err({invocation_error_name}::Runtime)?\n .map_err({invocation_error_name}::Domain)\n }}"
));
invocation_errors.push(format!(
"#[derive(Clone, Debug, PartialEq)]\npub enum {invocation_error_name} {{\n Domain({error_name}),\n Runtime(RuntimeFailure),\n}}\n"
));
} else {
event_operation_rows.push(format!(" {operation_const}_OPERATION,\n"));
provider_methods.push(format!(
" fn {}(&self, context: InvocationContext, event: {request_type});",
rust_field_name(&operation.name)
));
event_endpoint_arms.push(format!(
" {operation_const}_OPERATION => {{\n let Ok(event) = event.downcast::<{request_type}>() else {{\n return Box::pin(futures::future::ready(Err(RuntimeFailure::ProtocolViolation {{ capability: CAPABILITY_ID }})));\n }};\n let provider = Rc::clone(&self.provider);\n Box::pin(async move {{\n provider.{}(context, *event);\n Ok(())\n }})\n }}",
rust_field_name(&operation.name),
));
let field = rust_field_name(&operation.name);
client_fields.push(format!(" {field}: NativeEventHandle<{marker_name}>,"));
client_initializers.push(format!(
" {field}: dependencies.many_event::<{marker_name}>()?,"
));
client_methods.push(format!(
" pub async fn {field}(&self, event: {request_type}) -> Vec<EventPublishResult> {{\n self.{field}.publish({operation_const}_OPERATION, event).await\n }}\n\n pub async fn {field}_with_context(&self, context: InvocationContext, event: {request_type}) -> Vec<EventPublishResult> {{\n self.{field}.publish_with_context({operation_const}_OPERATION, context, event).await\n }}"
));
}
}
let request_endpoint_impl = if operation_rows.is_empty() {
String::new()
} else {
format!(
"impl<P: {capability_name}Provider> NativeRequestEndpoint for {capability_name}Endpoint<P> {{\n fn capability_id(&self) -> &'static str {{ CAPABILITY_ID }}\n fn descriptor_version(&self) -> &'static str {{ DESCRIPTOR_VERSION }}\n fn operations(&self) -> &'static [&'static str] {{ &[\n{} ] }}\n fn invoke(&self, operation: &str, request: Box<dyn std::any::Any>, context: InvocationContext) -> LocalBoxFuture<'static, Result<Result<Box<dyn std::any::Any>, Box<dyn std::any::Any>>, RuntimeFailure>> {{\n match operation {{\n{}\n _ => Box::pin(futures::future::ready(Err(RuntimeFailure::UnknownOperation {{ capability: CAPABILITY_ID, operation: operation.to_owned() }}))),\n }}\n }}\n}}\n\n",
operation_rows.concat(),
endpoint_arms.join(",\n")
)
};
let stream_endpoint_impl = if stream_operation_rows.is_empty() {
String::new()
} else {
format!(
"impl<P: {capability_name}Provider> NativeStreamEndpoint for {capability_name}Endpoint<P> {{\n fn capability_id(&self) -> &'static str {{ CAPABILITY_ID }}\n fn descriptor_version(&self) -> &'static str {{ DESCRIPTOR_VERSION }}\n fn operations(&self) -> &'static [&'static str] {{ &[\n{} ] }}\n fn open(&self, operation: &str, request: Box<dyn std::any::Any>, context: InvocationContext) -> LocalBoxFuture<'static, Result<Result<Box<dyn NativeStreamSession>, Box<dyn std::any::Any>>, RuntimeFailure>> {{\n match operation {{\n{}\n _ => Box::pin(futures::future::ready(Err(RuntimeFailure::UnknownOperation {{ capability: CAPABILITY_ID, operation: operation.to_owned() }}))),\n }}\n }}\n}}\n\n",
stream_operation_rows.concat(),
stream_endpoint_arms.join(",\n")
)
};
let event_endpoint_impl = if event_operation_rows.is_empty() {
String::new()
} else {
format!(
"impl<P: {capability_name}Provider> NativeEventEndpoint for {capability_name}Endpoint<P> {{\n fn capability_id(&self) -> &'static str {{ CAPABILITY_ID }}\n fn descriptor_version(&self) -> &'static str {{ DESCRIPTOR_VERSION }}\n fn operations(&self) -> &'static [&'static str] {{ &[\n{} ] }}\n fn publish(&self, operation: &str, event: Box<dyn std::any::Any>, context: InvocationContext) -> LocalBoxFuture<'static, Result<(), RuntimeFailure>> {{\n match operation {{\n{}\n _ => Box::pin(futures::future::ready(Err(RuntimeFailure::UnknownOperation {{ capability: CAPABILITY_ID, operation: operation.to_owned() }}))),\n }}\n }}\n}}\n\n",
event_operation_rows.concat(),
event_endpoint_arms.join(",\n")
)
};
let mut output = String::new();
output.push_str(GENERATED_HEADER);
let has_request_operations = !operation_rows.is_empty();
let has_stream_operations = !stream_operation_rows.is_empty();
let has_event_operations = !event_operation_rows.is_empty();
let mut kernel_imports = vec!["InvocationContext", "ModuleDependencies", "RuntimeFailure"];
if has_request_operations {
kernel_imports.extend([
"NativeRequestEndpoint",
"NativeRequestHandle",
"RequestCapability",
]);
}
if has_stream_operations {
kernel_imports.extend([
"NativeStream",
"NativeStreamEndpoint",
"NativeStreamHandle",
"NativeStreamSession",
"StreamCapability",
"StreamEvent",
]);
}
if has_event_operations {
kernel_imports.extend([
"EventCapability",
"EventPublishResult",
"NativeEventEndpoint",
"NativeEventHandle",
]);
}
writeln!(
output,
"use std::{{fmt, rc::Rc}};\nuse futures::future::LocalBoxFuture;\nuse lenso_kernel::{{{}}};\n",
kernel_imports.join(", ")
)
.expect("writing to a String cannot fail");
writeln!(
output,
"pub const CAPABILITY_ID: &str = {};",
quote_string(&contract.capability_id)
)
.expect("writing to a String cannot fail");
writeln!(
output,
"pub const DESCRIPTOR_VERSION: &str = {};",
quote_string(&contract.version)
)
.expect("writing to a String cannot fail");
writeln!(output, "pub const PORTABLE: bool = {};", contract.portable)
.expect("writing to a String cannot fail");
writeln!(
output,
"pub const CROSS_LANE_TRANSFER: bool = {};",
contract.cross_lane_transfer
)
.expect("writing to a String cannot fail");
writeln!(
output,
"pub const {capability_const}_CAPABILITY_ID: &str = CAPABILITY_ID;"
)
.expect("writing to a String cannot fail");
write!(
output,
"pub const {capability_const}_DESCRIPTOR_VERSION: &str = DESCRIPTOR_VERSION;\n\n"
)
.expect("writing to a String cannot fail");
for operation in &contract.operations {
let operation_const = format!("{}_OPERATION", screaming_snake_case(&operation.name));
writeln!(
output,
"pub const {operation_const}: &str = {};",
quote_string(&operation.name)
)
.expect("writing to a String cannot fail");
}
if contract_uses_bytes(contract) {
output.push_str(RUST_PORTABLE_TYPES);
} else {
output.push_str("\npub type Int64 = String;\npub type Uint64 = String;\npub type Bytes = String;\npub type Timestamp = String;\npub type Duration = String;\npub type OptionalValue<T> = Option<Option<T>>;\n\n");
}
output.push_str("#[allow(dead_code)]\nfn deserialize_required<'de, D, T>(deserializer: D) -> Result<T, D::Error>\nwhere\n D: serde::Deserializer<'de>,\n T: serde::Deserialize<'de>,\n{\n <T as serde::Deserialize>::deserialize(deserializer)\n}\n\n#[allow(dead_code, clippy::option_option)]\nfn deserialize_optional_value<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>\nwhere\n D: serde::Deserializer<'de>,\n T: serde::Deserialize<'de>,\n{\n Ok(Some(<Option<T> as serde::Deserialize>::deserialize(deserializer)?))\n}\n\n#[allow(dead_code)]\nfn validate_portable_json_value(value: &serde_json::Value) -> Result<(), String> {\n match value {\n serde_json::Value::Number(number) => {\n let safe = number.as_i64().is_some_and(|value| (-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&value))\n || number.as_u64().is_some_and(|value| value <= 9_007_199_254_740_991)\n || (number.is_f64() && number.as_f64().is_some_and(|value| value.is_finite() && (value.abs() <= 9_007_199_254_740_991.0 || value.fract() != 0.0)));\n if !safe {\n return Err(\"wire JSON contains an unsafe number\".to_owned());\n }\n }\n serde_json::Value::Array(values) => {\n for value in values {\n validate_portable_json_value(value)?;\n }\n }\n serde_json::Value::Object(values) => {\n for value in values.values() {\n validate_portable_json_value(value)?;\n }\n }\n serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => {}\n }\n Ok(())\n}\n\n#[allow(dead_code)]\nfn portable_json_error(detail: String) -> serde_json::Error {\n serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, detail))\n}\n\n#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]\npub struct UnknownDomainError {\n pub code: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub payload: Option<serde_json::Value>,\n #[serde(default, flatten)]\n pub extra: std::collections::BTreeMap<String, serde_json::Value>,\n}\n\n");
for declaration in types.declarations {
output.push_str(&declaration);
output.push('\n');
}
for marker in operation_markers {
output.push_str(&marker);
output.push('\n');
}
for codec in error_codecs {
output.push_str(&codec);
output.push('\n');
}
for codec in wire_codecs {
output.push_str(&codec);
output.push('\n');
}
write!(
output,
"pub trait {capability_name}Provider: fmt::Debug + 'static {{\n{}\n}}\n\n",
provider_methods.join("\n")
)
.expect("writing to a String cannot fail");
write!(
output,
"#[derive(Debug)]\npub struct {capability_name}Endpoint<P> {{ provider: Rc<P> }}\nimpl<P: {capability_name}Provider> {capability_name}Endpoint<P> {{\n pub fn new(provider: P) -> Self {{ Self {{ provider: Rc::new(provider) }} }}\n}}\n\n"
)
.expect("writing to a String cannot fail");
output.push_str(&request_endpoint_impl);
output.push_str(&stream_endpoint_impl);
output.push_str(&event_endpoint_impl);
let new_method = if contract.operations.len() == 1 {
let field = rust_field_name(&contract.operations[0].name);
let marker = &capability_name;
match contract.operations[0].interaction.as_str() {
"request" => format!(
" pub fn new(handle: NativeRequestHandle<{marker}>) -> Self {{\n Self {{ {field}: handle }}\n }}\n\n"
),
"stream" => format!(
" pub fn new(handle: NativeStreamHandle<{marker}>) -> Self {{\n Self {{ {field}: handle }}\n }}\n\n"
),
"event" => format!(
" pub fn new(handle: NativeEventHandle<{marker}>) -> Self {{\n Self {{ {field}: handle }}\n }}\n\n"
),
_ => unreachable!("Descriptor validation restricts interactions"),
}
} else {
String::new()
};
write!(
output,
"#[derive(Debug)]\npub struct {capability_name}Client {{\n{}\n}}\nimpl {capability_name}Client {{\n{} pub fn from_dependencies(dependencies: &ModuleDependencies) -> Result<Self, RuntimeFailure> {{\n Ok(Self {{\n{}\n }})\n }}\n\n{}\n}}\n\n",
client_fields.join("\n"),
new_method,
client_initializers.join("\n"),
client_methods.join("\n\n")
)
.expect("writing to a String cannot fail");
for error in invocation_errors {
output.push_str(&error);
}
format!("{}\n", output.trim_end())
}
#[allow(clippy::too_many_lines)]
fn generate_rust_error_codec(error_name: &str, variants: &[ErrorVariantIr]) -> String {
let mut output = String::new();
writeln!(output, "impl serde::Serialize for {error_name} {{").expect("String cannot fail");
output.push_str(
" fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n where\n S: serde::Serializer,\n {\n",
);
output.push_str(" use serde::ser::SerializeMap;\n");
output.push_str(" match self {\n");
for variant in variants {
if variant.structured {
if variant.payload.is_some() {
if variant.payload_required {
writeln!(
output,
" Self::{} {{ payload }} => {{\n let mut map = serializer.serialize_map(Some(2))?;\n map.serialize_entry(\"code\", {})?;\n map.serialize_entry(\"payload\", payload)?;\n map.end()\n }},",
variant.name,
quote_string(&variant.code)
)
.expect("String cannot fail");
} else {
writeln!(
output,
" Self::{} {{ payload }} => {{\n let mut map = serializer.serialize_map(Some(if payload.is_some() {{ 2 }} else {{ 1 }}))?;\n map.serialize_entry(\"code\", {})?;\n if let Some(payload) = payload {{\n map.serialize_entry(\"payload\", payload)?;\n }}\n map.end()\n }},",
variant.name,
quote_string(&variant.code)
)
.expect("String cannot fail");
}
} else {
writeln!(
output,
" Self::{} => {{\n let mut map = serializer.serialize_map(Some(1))?;\n map.serialize_entry(\"code\", {})?;\n map.end()\n }},",
variant.name,
quote_string(&variant.code)
)
.expect("String cannot fail");
}
} else {
writeln!(
output,
" Self::{} => serializer.serialize_str({}),",
variant.name,
quote_string(&variant.code)
)
.expect("String cannot fail");
}
}
output.push_str(" Self::Unknown(value) => {\n let mut map = serializer.serialize_map(Some(1 + usize::from(value.payload.is_some()) + value.extra.len()))?;\n map.serialize_entry(\"code\", &value.code)?;\n if let Some(payload) = &value.payload {\n map.serialize_entry(\"payload\", payload)?;\n }\n for (key, extra) in &value.extra {\n map.serialize_entry(key, extra)?;\n }\n map.end()\n },\n }\n }\n}\n\n");
writeln!(
output,
"impl<'de> serde::Deserialize<'de> for {error_name} {{"
)
.expect("String cannot fail");
output.push_str(
" fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n where\n D: serde::Deserializer<'de>,\n {\n let value = <serde_json::Value as serde::Deserialize>::deserialize(deserializer)?;\n match value {\n serde_json::Value::String(code) => match code.as_str() {\n",
);
for variant in variants.iter().filter(|variant| !variant.structured) {
writeln!(
output,
" {} => Ok(Self::{}),",
quote_string(&variant.code),
variant.name
)
.expect("String cannot fail");
}
output.push_str(
" _ => Ok(Self::Unknown(UnknownDomainError { code, payload: None, extra: std::collections::BTreeMap::new() })),\n },\n serde_json::Value::Object(mut object) => {\n let Some(code) = object.remove(\"code\").and_then(|value| value.as_str().map(ToOwned::to_owned)) else {\n return Err(serde::de::Error::custom(\"Domain Error object is missing a string code\"));\n };\n",
);
if variants.iter().any(|variant| variant.structured) {
output.push_str(" match code.as_str() {\n");
for variant in variants.iter().filter(|variant| variant.structured) {
if variant.payload.is_some() {
if variant.payload_required {
writeln!(
output,
" {} => {{\n let payload = object.remove(\"payload\").ok_or_else(|| serde::de::Error::custom(\"structured Domain Error is missing a payload\"))?;\n let payload = serde_json::from_value(payload).map_err(serde::de::Error::custom)?;\n Ok(Self::{} {{ payload }})\n }},",
quote_string(&variant.code),
variant.name
)
.expect("String cannot fail");
} else {
writeln!(
output,
" {} => {{\n let payload = match object.remove(\"payload\") {{\n Some(payload) => Some(serde_json::from_value(payload).map_err(serde::de::Error::custom)?),\n None => None,\n }};\n Ok(Self::{} {{ payload }})\n }},",
quote_string(&variant.code),
variant.name
)
.expect("String cannot fail");
}
} else {
writeln!(
output,
" {} => Ok(Self::{}),",
quote_string(&variant.code),
variant.name
)
.expect("String cannot fail");
}
}
output.push_str(
" _ => {\n let payload = object.remove(\"payload\");\n let extra = object.into_iter().collect::<std::collections::BTreeMap<_, _>>();\n Ok(Self::Unknown(UnknownDomainError { code, payload, extra }))\n }\n }\n }\n",
);
} else {
output.push_str(
" let payload = object.remove(\"payload\");\n let extra = object.into_iter().collect::<std::collections::BTreeMap<_, _>>();\n Ok(Self::Unknown(UnknownDomainError { code, payload, extra }))\n }\n",
);
}
output.push_str(
" other => Err(serde::de::Error::custom(format!(\"Domain Error must be a string or object, got {other}\"))),\n }\n }\n}\n",
);
output
}
fn generate_rust_wire_codecs(
operation: &str,
request_type: &str,
response_type: &str,
error_type: &str,
) -> String {
let stem = snake_case(operation);
format!(
"pub fn encode_{stem}_request(value: &{request_type}) -> Result<String, serde_json::Error> {{ let value = serde_json::to_value(value)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::to_string(&value) }}\npub fn decode_{stem}_request(wire: &str) -> Result<{request_type}, serde_json::Error> {{ let value: serde_json::Value = serde_json::from_str(wire)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::from_value(value) }}\npub fn encode_{stem}_response(value: &{response_type}) -> Result<String, serde_json::Error> {{ let value = serde_json::to_value(value)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::to_string(&value) }}\npub fn decode_{stem}_response(wire: &str) -> Result<{response_type}, serde_json::Error> {{ let value: serde_json::Value = serde_json::from_str(wire)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::from_value(value) }}\npub fn encode_{stem}_error(value: &{error_type}) -> Result<String, serde_json::Error> {{ let value = serde_json::to_value(value)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::to_string(&value) }}\npub fn decode_{stem}_error(wire: &str) -> Result<{error_type}, serde_json::Error> {{ let value: serde_json::Value = serde_json::from_str(wire)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::from_value(value) }}\n"
)
}
fn generate_rust_event_codecs(operation: &str, event_type: &str) -> String {
let stem = snake_case(operation);
format!(
"pub fn encode_{stem}_event(value: &{event_type}) -> Result<String, serde_json::Error> {{ let value = serde_json::to_value(value)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::to_string(&value) }}\npub fn decode_{stem}_event(wire: &str) -> Result<{event_type}, serde_json::Error> {{ let value: serde_json::Value = serde_json::from_str(wire)?; validate_portable_json_value(&value).map_err(portable_json_error)?; serde_json::from_value(value) }}\n"
)
}
fn generate_typescript_codecs(
operation: &str,
request_type: &str,
response_type: &str,
error_type: &str,
variants: &[ErrorVariantIr],
) -> String {
let stem = pascal_case(operation);
let mut known_strings = String::new();
for variant in variants.iter().filter(|variant| !variant.structured) {
writeln!(
known_strings,
" if (value === {}) return value as {error_type};",
quote_string(&variant.code)
)
.expect("String cannot fail");
}
format!(
"export function encode{stem}Request(value: {request_type}): string {{ validatePortableJson(value); const wire = JSON.stringify(value); if (wire === undefined) throw new Error(\"request cannot be encoded\"); return wire; }}\nexport function decode{stem}Request(wire: string): {request_type} {{ const value: unknown = JSON.parse(wire); validatePortableJson(value); return value as {request_type}; }}\nexport function encode{stem}Response(value: {response_type}): string {{ validatePortableJson(value); const wire = JSON.stringify(value); if (wire === undefined) throw new Error(\"response cannot be encoded\"); return wire; }}\nexport function decode{stem}Response(wire: string): {response_type} {{ const value: unknown = JSON.parse(wire); validatePortableJson(value); return value as {response_type}; }}\nexport function encode{stem}Error(value: {error_type}): string {{ validatePortableJson(value); const wire = JSON.stringify(value); if (wire === undefined) throw new Error(\"Domain Error cannot be encoded\"); return wire; }}\nexport function decode{stem}Error(wire: string): {error_type} {{\n const value: unknown = JSON.parse(wire);\n validatePortableJson(value);\n if (typeof value === \"string\") {{\n{known_strings} return {{ code: value }} as {error_type};\n }}\n if (isRecord(value) && typeof value.code === \"string\") return value as {error_type};\n throw new Error(\"Domain Error must be a string or object\");\n}}\n"
)
}
fn generate_typescript_event_codecs(operation: &str, event_type: &str) -> String {
let stem = pascal_case(operation);
format!(
"export function encode{stem}Event(value: {event_type}): string {{ validatePortableJson(value); const wire = JSON.stringify(value); if (wire === undefined) throw new Error(\"event cannot be encoded\"); return wire; }}\nexport function decode{stem}Event(wire: string): {event_type} {{ const value: unknown = JSON.parse(wire); validatePortableJson(value); return value as {event_type}; }}\n"
)
}
#[allow(clippy::too_many_lines)]
fn generate_typescript(contract: &ContractIr) -> String {
let capability_name = pascal_case(
contract
.capability_id
.split('@')
.next()
.and_then(|identity| identity.rsplit('.').next())
.unwrap_or("Capability"),
);
let mut types = TypeScriptTypes::new();
let mut clients = Vec::new();
let mut providers = Vec::new();
let mut errors = Vec::new();
let mut codecs = Vec::new();
let has_event_operations = contract
.operations
.iter()
.any(|operation| operation.interaction == "event");
for operation in &contract.operations {
let operation_name = pascal_case(&operation.name);
let request_name = format!("{operation_name}Request");
let response_name = format!("{operation_name}Response");
let error_name = format!("{operation_name}Error");
let request_type = types.type_for(&operation.request, &request_name);
let response_type = types.type_for(&operation.response, &response_name);
let variants = &operation.domain_errors;
let error_type = if variants.is_empty() {
"UnknownDomainError".to_owned()
} else {
let mut values = variants
.iter()
.map(|variant| {
if let Some(payload) = &variant.payload {
let payload_name = format!("{error_name}{}Payload", variant.name);
let payload_type =
types.type_for_non_null(payload.non_null(), &payload_name);
let payload_type = if payload.is_nullable() {
format!("{payload_type} | null")
} else {
payload_type
};
let payload_optional = if variant.payload_required { "" } else { "?" };
format!(
"{{ readonly code: {}; readonly payload{payload_optional}: {payload_type} }}",
quote_string(&variant.code),
)
} else if variant.structured {
format!("{{ readonly code: {} }}", quote_string(&variant.code))
} else {
quote_string(&variant.code)
}
})
.collect::<Vec<_>>();
values.push("UnknownDomainError".to_owned());
values.join(" | ")
};
let invocation_error_name = format!("{operation_name}InvocationError");
let result_name = format!("{operation_name}Result");
let result_value_type = match operation.interaction.as_str() {
"stream" => format!("StreamSession<{response_type}, {error_name}>"),
"event" => "ReadonlyArray<EventPublishResult>".to_owned(),
_ => response_type.clone(),
};
if operation.interaction == "event" {
errors.push(format!(
"export type {error_name} = {error_type};\nexport type {result_name} = ReadonlyArray<EventPublishResult>;"
));
} else {
errors.push(format!(
"export type {error_name} = {error_type};\nexport type {invocation_error_name} = {{ readonly kind: \"domain\"; readonly error: {error_name} }} | {{ readonly kind: \"runtime\"; readonly error: RuntimeFailure }};\nexport type {result_name} = {{ readonly ok: true; readonly value: {result_value_type} }} | {{ readonly ok: false; readonly error: {invocation_error_name} }};"
));
}
codecs.push(generate_typescript_codecs(
&operation.name,
&request_type,
&response_type,
&error_name,
variants,
));
if operation.interaction == "event" {
codecs.push(generate_typescript_event_codecs(
&operation.name,
&request_type,
));
}
if operation.interaction == "request" {
clients.push(format!(
" {}(request: {request_type}, context?: InvocationContext): Promise<{result_name}>;",
typescript_property_name(&snake_case(&operation.name)),
));
providers.push(format!(
" {}(context: InvocationContext, request: {request_type}): Promise<{result_name}>;",
typescript_property_name(&snake_case(&operation.name)),
));
} else if operation.interaction == "stream" {
clients.push(format!(
" {}(request: {request_type}, context?: InvocationContext): Promise<{result_name}>;",
typescript_property_name(&snake_case(&operation.name)),
));
providers.push(format!(
" {}(context: InvocationContext, request: {request_type}): Promise<{result_name}>;",
typescript_property_name(&snake_case(&operation.name)),
));
} else {
clients.push(format!(
" {}(event: {request_type}, context?: InvocationContext): Promise<{result_name}>;",
typescript_property_name(&snake_case(&operation.name)),
));
providers.push(format!(
" {}(context: InvocationContext, event: {request_type}): void;",
typescript_property_name(&snake_case(&operation.name)),
));
}
}
let mut output = String::new();
output.push_str(TYPESCRIPT_HEADER);
writeln!(
output,
"export const CAPABILITY_ID = {};",
quote_string(&contract.capability_id)
)
.expect("writing to a String cannot fail");
writeln!(
output,
"export const DESCRIPTOR_VERSION = {};",
quote_string(&contract.version)
)
.expect("writing to a String cannot fail");
writeln!(output, "export const PORTABLE = {};", contract.portable)
.expect("writing to a String cannot fail");
write!(
output,
"export const CROSS_LANE_TRANSFER = {};\n\n",
contract.cross_lane_transfer
)
.expect("writing to a String cannot fail");
output.push_str("export type Int64 = string & { readonly __lensoInt64: unique symbol };\nexport type Uint64 = string & { readonly __lensoUint64: unique symbol };\nexport type Bytes = string & { readonly __lensoBytes: unique symbol };\nexport type Timestamp = string & { readonly __lensoTimestamp: unique symbol };\nexport type Duration = string & { readonly __lensoDuration: unique symbol };\nexport type OptionalValue<T> = T | null | undefined;\n\nexport interface InvocationContext {\n readonly requestId: Uint64;\n readonly deadline?: Duration;\n readonly cancelled: boolean;\n readonly callerInstance?: string;\n readonly extensions?: Record<string, unknown>;\n}\n\nexport type RuntimeFailure = { readonly kind: \"unavailable\" | \"unknown_operation\" | \"ambiguous_binding\" | \"protocol_violation\" | \"missing_module_factory\" | \"unavailable_execution_class\" | \"invalid_resolved_plan\" | \"admission_closed\" | \"resource_exhausted\" | \"deadline_exceeded\" | \"cancelled\" | \"internal\" | \"module_failure\" | \"module_restart_exhausted\"; readonly detail?: unknown; readonly [key: string]: unknown };\nexport type UnknownDomainError = { readonly code: string; readonly payload?: unknown; readonly [key: string]: unknown };\n\nexport type StreamEvent<Message, DomainError> =\n | { readonly kind: \"message\"; readonly message: Message }\n | { readonly kind: \"peer_half_closed\" }\n | { readonly kind: \"terminal\"; readonly outcome: { readonly ok: true } | { readonly ok: false; readonly error: DomainError } };\nexport interface StreamSession<Message, DomainError> {\n send(message: Message): Promise<void>;\n receive(): Promise<StreamEvent<Message, DomainError>>;\n closeSend(): Promise<void>;\n cancel(): void;\n}\n\n");
if has_event_operations {
output.push_str("export type EventAdmission = \"accepted\" | \"unavailable\" | \"exhausted\";\nexport interface EventPublishResult {\n readonly subscriberInstance: string;\n readonly admission: EventAdmission;\n}\n\n");
}
for declaration in types.declarations {
output.push_str(&declaration);
output.push('\n');
}
for error in errors {
output.push_str(&error);
output.push('\n');
}
output.push_str("function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\nfunction validatePortableJson(value: unknown): void {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) throw new Error(\"wire JSON contains an unsafe number\");\n return;\n }\n if (Array.isArray(value)) {\n for (const item of value) validatePortableJson(item);\n return;\n }\n if (isRecord(value)) {\n for (const item of Object.values(value)) validatePortableJson(item);\n }\n}\n\n");
for codec in codecs {
output.push_str(&codec);
output.push('\n');
}
write!(
output,
"\nexport interface {capability_name}Client {{\n{}\n}}\n\nexport interface {capability_name}Provider {{\n{}\n}}\n",
clients.join("\n"),
providers.join("\n")
)
.expect("writing to a String cannot fail");
output.push_str("\nexport const portableValueProfile = {\n int64: \"decimal-string\",\n uint64: \"decimal-string\",\n bytes: \"base64-string\",\n timestamp: \"RFC3339-string\",\n duration: \"ISO8601-string\",\n missingAndNull: \"distinct\",\n} as const;\n");
output
}