//! Portable Capability Descriptor validation and binding generation.
//!
//! A locked Descriptor and its package-local JSON Schemas are the portable
//! cross-language authority. They may be derived from compiled source types,
//! but every backend consumes the same checked snapshot. This crate deliberately
//! lives above the Kernel: it checks evolution rules and emits deterministic
//! Rust and TypeScript artifacts before an App is booted.
use std::{
collections::{BTreeMap, BTreeSet, HashSet},
error::Error,
fmt,
fmt::Write as _,
fs,
path::{Path, PathBuf},
};
use regex::Regex;
use serde_json::{Map, Value};
mod browser;
mod ir;
mod rust_debug;
mod source;
mod wit;
pub use browser::generate_browser_request_client;
pub use source::{check_source_snapshot, write_source_snapshot};
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 {
name: schema_declared_type_name(object),
values: 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 mut properties = object
.get("properties")
.and_then(Value::as_object)
.into_iter()
.flatten()
.collect::<Vec<_>>();
// Projection order is intentionally independent from JSON map
// insertion order. Source authoring preserves declaration order
// for locked snapshots; language backends retain the historical
// canonical name order.
properties.sort_unstable_by_key(|(name, _)| *name);
let fields = properties
.into_iter()
.map(|(name, schema)| {
let ty = type_ir_from_schema(schema);
let ty = if name.ends_with("_json") && ty == TypeIr::String {
TypeIr::RawJson
} else {
ty
};
FieldIr {
name: name.clone(),
required: required.contains(name),
sensitive: schema
.get("x-lenso-sensitive")
.and_then(Value::as_bool)
.unwrap_or(false),
ty,
}
})
.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 {
name: schema_declared_type_name(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
}
fn schema_declared_type_name(schema: &Map<String, Value>) -> Option<String> {
schema
.get("title")
.and_then(Value::as_str)
.filter(|title| !title.is_empty())
.map(pascal_case)
}
/// 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,
}
/// One language projection emitted from a portable Capability contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProjectionLanguage {
/// Native Rust value, consumer, and provider bindings.
Rust,
/// TypeScript value, consumer, and provider bindings.
TypeScript,
/// WebAssembly Component Model WIT provider and consumer worlds.
Wit,
/// Rust bindings plus the generated byte-Adapter Capability codec.
RustRuntime,
}
/// One independently generated language projection.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedProjection {
/// Metadata embedded in the generated projection.
pub metadata: GeneratedMetadata,
/// Selected target language.
pub language: ProjectionLanguage,
/// Generated provider, client, value, and error bindings.
pub source: String,
}
/// Compatibility aggregate for callers that intentionally generate both projections.
#[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 portable JSON Schema shape has no exact Component Model representation.
UnsupportedWit { detail: 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::UnsupportedWit { detail } => {
write!(formatter, "unsupported WIT projection: {detail}")
}
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_context".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 mut request_schema = resolve_refs(
&request_source,
&request_source,
&request_schema_path,
&package_root,
&mut ref_stack,
)?;
normalize_nullable_unions(&mut request_schema);
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 mut response_schema = resolve_refs(
&response_source,
&response_source,
&response_schema_path,
&package_root,
&mut ref_stack,
)?;
normalize_nullable_unions(&mut response_schema);
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 mut domain_error_schema = resolve_refs(
&domain_error_source,
&domain_error_source,
&domain_error_schema_path,
&package_root,
&mut ref_stack,
)?;
normalize_nullable_unions(&mut domain_error_schema);
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(),
}
})?;
restore_reference_type_name(&mut merged, reference);
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 reference_definition_name(reference: &str) -> Option<String> {
let (_, fragment) = reference.rsplit_once('#')?;
let name = fragment.strip_prefix("/$defs/")?;
if name.is_empty() || name.contains('/') {
return None;
}
Some(name.replace("~1", "/").replace("~0", "~"))
}
fn restore_reference_type_name(schema: &mut Map<String, Value>, reference: &str) {
if schema.get("title").is_none()
&& let Some(name) = reference_definition_name(reference)
{
schema.insert("title".to_owned(), Value::String(name));
}
}
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 normalize_nullable_unions(value: &mut Value) {
match value {
Value::Object(object) => {
for child in object.values_mut() {
normalize_nullable_unions(child);
}
let nullable = ["anyOf", "oneOf"].into_iter().find_map(|key| {
let alternatives = object.get(key)?.as_array()?;
if alternatives.len() != 2 {
return None;
}
let null_index = alternatives.iter().position(|alternative| {
alternative.get("type").and_then(Value::as_str) == Some("null")
})?;
let non_null = alternatives.get(1 - null_index)?.as_object()?.clone();
let schema_type = non_null
.get("type")
.and_then(Value::as_str)
.map(ToOwned::to_owned)?;
Some((key, schema_type, non_null))
});
if let Some((union_key, schema_type, mut normalized)) = nullable {
normalized.insert(
"type".to_owned(),
Value::Array(vec![
Value::String(schema_type),
Value::String("null".to_owned()),
]),
);
for (key, child) in std::mem::take(object) {
if key != union_key {
normalized.entry(key).or_insert(child);
}
}
*object = normalized;
}
}
Value::Array(values) => {
for child in values {
normalize_nullable_unions(child);
}
}
_ => {}
}
}
const SUPPORTED_SCHEMA_KEYWORDS: &[&str] = &[
"$defs",
"additionalProperties",
"anyOf",
"const",
"enum",
"exclusiveMaximum",
"exclusiveMinimum",
"format",
"items",
"maxItems",
"maxLength",
"maximum",
"minItems",
"minLength",
"minimum",
"oneOf",
"pattern",
"properties",
"required",
"type",
"uniqueItems",
];
const HARMLESS_SCHEMA_ANNOTATIONS: &[&str] = &[
"$anchor",
"$comment",
"$dynamicAnchor",
"$id",
"$schema",
"default",
"deprecated",
"description",
"examples",
"readOnly",
"title",
"writeOnly",
"x-lenso-sensitive",
];
const PORTABLE_SCHEMA_TYPES: &[&str] = &[
"array", "boolean", "integer", "null", "number", "object", "string",
];
const PORTABLE_STRING_FORMATS: &[&str] = &["byte", "date-time", "duration", "int64", "uint64"];
const MAX_PORTABLE_PATTERN_CHARS: usize = 4_096;
const MAX_PORTABLE_REPETITION: u32 = 10_000;
const PORTABLE_UNICODE_PROPERTY_ESCAPE: &str = r"\p{Letter}";
fn validate_schema_profile(schema: &Value, source_path: &Path) -> Result<(), CodegenError> {
let Some(object) = schema.as_object() else {
return Err(unsupported_schema(
source_path,
"a JSON Schema must be an object",
));
};
for keyword in object.keys() {
if !SUPPORTED_SCHEMA_KEYWORDS.contains(&keyword.as_str())
&& !HARMLESS_SCHEMA_ANNOTATIONS.contains(&keyword.as_str())
{
return Err(unsupported_schema(
source_path,
format!("Schema keyword `{keyword}` is outside the portable profile"),
));
}
}
validate_schema_type_keyword(object, source_path)?;
validate_schema_composition_keywords(object, source_path)?;
validate_schema_scalar_keywords(object, source_path)?;
validate_schema_numeric_keywords(object, source_path)?;
validate_schema_children(object, source_path)
}
fn validate_schema_type_keyword(
object: &Map<String, Value>,
source_path: &Path,
) -> Result<(), CodegenError> {
if let Some(schema_type) = object.get("type") {
match schema_type {
Value::String(schema_type) => validate_schema_type(schema_type, source_path)?,
Value::Array(types) if !types.is_empty() => {
let mut unique = BTreeSet::new();
for schema_type in types {
let Some(schema_type) = schema_type.as_str() else {
return Err(unsupported_schema(
source_path,
"Schema type unions must contain strings",
));
};
validate_schema_type(schema_type, source_path)?;
if !unique.insert(schema_type) {
return Err(unsupported_schema(
source_path,
"Schema type unions must be unique",
));
}
}
}
Value::Array(_) => {
return Err(unsupported_schema(
source_path,
"a Schema type union cannot be empty",
));
}
_ => {
return Err(unsupported_schema(
source_path,
"Schema `type` must be a string or array",
));
}
}
}
Ok(())
}
fn validate_schema_composition_keywords(
object: &Map<String, Value>,
source_path: &Path,
) -> Result<(), CodegenError> {
for keyword in ["oneOf", "anyOf"] {
if let Some(value) = object.get(keyword) {
let Some(alternatives) = value.as_array().filter(|values| !values.is_empty()) else {
return Err(unsupported_schema(
source_path,
format!("Schema `{keyword}` must be a non-empty array of Schemas"),
));
};
for alternative in alternatives {
validate_schema_profile(alternative, source_path)?;
}
}
}
Ok(())
}
fn validate_schema_scalar_keywords(
object: &Map<String, Value>,
source_path: &Path,
) -> Result<(), CodegenError> {
if let Some(values) = object.get("enum")
&& values.as_array().is_none_or(Vec::is_empty)
{
return Err(unsupported_schema(
source_path,
"Schema `enum` must be a non-empty array",
));
}
if let Some(required) = object.get("required")
&& required
.as_array()
.is_none_or(|values| values.iter().any(|value| !value.is_string()))
{
return Err(unsupported_schema(
source_path,
"Schema `required` must be an array of strings",
));
}
if let Some(pattern) = object.get("pattern") {
let Some(pattern) = pattern.as_str() else {
return Err(unsupported_schema(
source_path,
"Schema `pattern` must be a string",
));
};
compile_portable_pattern(pattern).map_err(|error| {
unsupported_schema(
source_path,
format!("Schema `pattern` is outside the portable regex subset: {error}"),
)
})?;
}
if let Some(format) = object.get("format") {
let Some(format) = format.as_str() else {
return Err(unsupported_schema(
source_path,
"Schema `format` must be a string",
));
};
if !PORTABLE_STRING_FORMATS.contains(&format) {
return Err(unsupported_schema(
source_path,
format!("format `{format}` is outside the portable value profile"),
));
}
}
if object
.get("uniqueItems")
.is_some_and(|value| !value.is_boolean())
{
return Err(unsupported_schema(
source_path,
"Schema `uniqueItems` must be a boolean",
));
}
Ok(())
}
fn validate_schema_numeric_keywords(
object: &Map<String, Value>,
source_path: &Path,
) -> Result<(), CodegenError> {
for keyword in ["minLength", "maxLength", "minItems", "maxItems"] {
if let Some(value) = object.get(keyword)
&& non_negative_safe_schema_integer(value).is_none()
{
return Err(unsupported_schema(
source_path,
format!("Schema `{keyword}` must be a non-negative safe integer"),
));
}
}
for keyword in ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"] {
if let Some(value) = object.get(keyword)
&& value.as_f64().is_none_or(|number| !number.is_finite())
{
return Err(unsupported_schema(
source_path,
format!("Schema `{keyword}` must be a finite number"),
));
}
}
Ok(())
}
fn validate_schema_children(
object: &Map<String, Value>,
source_path: &Path,
) -> Result<(), CodegenError> {
if let Some(items) = object.get("items") {
validate_schema_profile(items, source_path)?;
}
for keyword in ["properties", "$defs"] {
if let Some(value) = object.get(keyword) {
let Some(schemas) = value.as_object() else {
return Err(unsupported_schema(
source_path,
format!("Schema `{keyword}` must be an object of Schemas"),
));
};
for child in schemas.values() {
validate_schema_profile(child, source_path)?;
}
}
}
if let Some(additional) = object.get("additionalProperties")
&& !additional.is_boolean()
{
validate_schema_profile(additional, source_path)?;
}
Ok(())
}
fn validate_schema_type(schema_type: &str, source_path: &Path) -> Result<(), CodegenError> {
if PORTABLE_SCHEMA_TYPES.contains(&schema_type) {
Ok(())
} else {
Err(unsupported_schema(
source_path,
format!("Schema type `{schema_type}` is outside the portable profile"),
))
}
}
/// Compiles only the regular-expression syntax whose match semantics are shared
/// by Rust `regex` and ECMAScript Unicode-mode `RegExp` for portable Schemas.
/// Engine-specific shorthand classes, anchors, wildcard dots, lookarounds,
/// backreferences, inline flags, and backtracking-unsafe repetition shapes fail
/// closed before either runtime uses them.
fn compile_portable_pattern(pattern: &str) -> Result<Regex, String> {
if !has_portable_pattern_syntax(pattern) {
return Err("pattern uses syntax outside the shared Rust/ECMAScript subset".to_owned());
}
Regex::new(pattern).map_err(|error| error.to_string())
}
#[derive(Clone, Copy, Debug, Default)]
#[expect(
clippy::struct_excessive_bools,
reason = "these are independent regex risk facts mirrored explicitly in the TypeScript gate"
)]
struct PortablePatternAtom {
can_match_empty: bool,
has_variable_shape: bool,
is_quantified: bool,
prefix_can_match_empty: bool,
contains_variable_repetition: bool,
contains_alternation: bool,
is_group: bool,
}
#[derive(Debug)]
#[expect(
clippy::struct_excessive_bools,
reason = "branch-local and group-wide regex risk facts are independent parser state"
)]
struct PortablePatternFrame {
sequence_can_match_empty: bool,
earlier_branch_can_match_empty: bool,
contains_variable_repetition: bool,
contains_alternation: bool,
branch_has_variable_shape: bool,
last_atom: Option<PortablePatternAtom>,
}
impl Default for PortablePatternFrame {
fn default() -> Self {
Self {
sequence_can_match_empty: true,
earlier_branch_can_match_empty: false,
contains_variable_repetition: false,
contains_alternation: false,
branch_has_variable_shape: false,
last_atom: None,
}
}
}
#[derive(Clone, Copy, Debug)]
struct PortableRepetition {
minimum: u32,
maximum: Option<u32>,
}
impl PortableRepetition {
fn has_variable_extent(self) -> bool {
self.maximum != Some(self.minimum)
}
}
#[expect(
clippy::too_many_lines,
reason = "keeping the single-pass scanner locally isomorphic with the TypeScript gate makes parity auditable"
)]
fn has_portable_pattern_syntax(pattern: &str) -> bool {
if pattern.chars().count() > MAX_PORTABLE_PATTERN_CHARS || !has_portable_outer_anchors(pattern)
{
return false;
}
let bytes = pattern.as_bytes();
let mut index = 0;
let mut in_class = false;
let mut class_start = None;
let mut frames = vec![PortablePatternFrame::default()];
while index < bytes.len() {
if in_class {
match bytes[index] {
b'\\' => {
if pattern[index..].starts_with(PORTABLE_UNICODE_PROPERTY_ESCAPE) {
index += PORTABLE_UNICODE_PROPERTY_ESCAPE.len();
continue;
}
let Some(&escaped) = bytes.get(index + 1) else {
return false;
};
if !matches!(
escaped,
b'^' | b'$'
| b'\\'
| b'.'
| b'*'
| b'+'
| b'?'
| b'('
| b')'
| b'['
| b']'
| b'{'
| b'}'
| b'|'
| b'/'
| b'-'
) {
return false;
}
index += 2;
}
b']' => {
let Some(start) = class_start.take() else {
return false;
};
if matches!(&pattern[start..index], "" | "^") {
return false;
}
in_class = false;
let Some(frame) = frames.last_mut() else {
return false;
};
if !push_portable_pattern_atom(frame, PortablePatternAtom::default()) {
return false;
}
index += 1;
}
b'[' => return false,
operator @ (b'&' | b'-' | b'~' | b'|')
if bytes.get(index + 1) == Some(&operator) =>
{
return false;
}
byte if byte.is_ascii_control() => return false,
byte if byte.is_ascii() => index += 1,
_ => {
let Some(character) = pattern[index..].chars().next() else {
return false;
};
if character.is_control() {
return false;
}
index += character.len_utf8();
}
}
continue;
}
match bytes[index] {
b'\\' => {
if pattern[index..].starts_with(PORTABLE_UNICODE_PROPERTY_ESCAPE) {
index += PORTABLE_UNICODE_PROPERTY_ESCAPE.len();
} else {
let Some(&escaped) = bytes.get(index + 1) else {
return false;
};
if !matches!(
escaped,
b'^' | b'$'
| b'\\'
| b'.'
| b'*'
| b'+'
| b'?'
| b'('
| b')'
| b'['
| b']'
| b'{'
| b'}'
| b'|'
| b'/'
) {
return false;
}
index += 2;
}
let Some(frame) = frames.last_mut() else {
return false;
};
if !push_portable_pattern_atom(frame, PortablePatternAtom::default()) {
return false;
}
}
b'[' => {
in_class = true;
class_start = Some(index + 1);
index += 1;
}
b']' | b'}' | b'.' => return false,
b'(' => {
if bytes.get(index + 1) == Some(&b'?') {
if !pattern[index..].starts_with("(?:") {
return false;
}
index += 3;
} else {
index += 1;
}
frames.push(PortablePatternFrame::default());
}
b')' => {
if frames.len() == 1 {
return false;
}
let Some(group) = frames.pop() else {
return false;
};
let can_match_empty =
group.earlier_branch_can_match_empty || group.sequence_can_match_empty;
if can_match_empty {
return false;
}
let atom = PortablePatternAtom {
can_match_empty,
has_variable_shape: group.contains_variable_repetition
|| group.contains_alternation,
contains_variable_repetition: group.contains_variable_repetition,
contains_alternation: group.contains_alternation,
is_group: true,
..PortablePatternAtom::default()
};
let Some(parent) = frames.last_mut() else {
return false;
};
if !push_portable_pattern_atom(parent, atom) {
return false;
}
index += 1;
}
b'|' => {
if frames.len() == 1 {
return false;
}
let Some(frame) = frames.last_mut() else {
return false;
};
frame.contains_alternation = true;
frame.earlier_branch_can_match_empty |= frame.sequence_can_match_empty;
frame.sequence_can_match_empty = true;
frame.branch_has_variable_shape = false;
frame.last_atom = None;
index += 1;
}
b'*' | b'+' | b'?' => {
let repetition = match bytes[index] {
b'*' => PortableRepetition {
minimum: 0,
maximum: None,
},
b'+' => PortableRepetition {
minimum: 1,
maximum: None,
},
b'?' => PortableRepetition {
minimum: 0,
maximum: Some(1),
},
_ => unreachable!(),
};
let Some(frame) = frames.last_mut() else {
return false;
};
if !apply_portable_repetition(frame, repetition) {
return false;
}
index += 1;
}
b'{' => {
let Some(close_offset) = pattern[index + 1..].find('}') else {
return false;
};
let close = index + 1 + close_offset;
let Some(repetition) = parse_portable_repetition(&pattern[index + 1..close]) else {
return false;
};
let Some(frame) = frames.last_mut() else {
return false;
};
if !apply_portable_repetition(frame, repetition) {
return false;
}
index = close + 1;
}
b'^' | b'$' => {
let Some(frame) = frames.last_mut() else {
return false;
};
frame.last_atom = None;
index += 1;
}
byte if byte.is_ascii_control() => return false,
byte if byte.is_ascii() => {
let Some(frame) = frames.last_mut() else {
return false;
};
if !push_portable_pattern_atom(frame, PortablePatternAtom::default()) {
return false;
}
index += 1;
}
_ => {
let Some(character) = pattern[index..].chars().next() else {
return false;
};
if character.is_control() {
return false;
}
let Some(frame) = frames.last_mut() else {
return false;
};
if !push_portable_pattern_atom(frame, PortablePatternAtom::default()) {
return false;
}
index += character.len_utf8();
}
}
}
!in_class && frames.len() == 1
}
fn has_portable_outer_anchors(pattern: &str) -> bool {
let bytes = pattern.as_bytes();
if bytes.first() != Some(&b'^') || bytes.last() != Some(&b'$') {
return false;
}
bytes[..bytes.len() - 1]
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count()
% 2
== 0
}
fn push_portable_pattern_atom(
frame: &mut PortablePatternFrame,
mut atom: PortablePatternAtom,
) -> bool {
if atom.has_variable_shape && frame.branch_has_variable_shape {
return false;
}
atom.prefix_can_match_empty = frame.sequence_can_match_empty;
frame.sequence_can_match_empty &= atom.can_match_empty;
frame.branch_has_variable_shape |= atom.has_variable_shape;
frame.contains_variable_repetition |= atom.contains_variable_repetition;
frame.contains_alternation |= atom.contains_alternation;
frame.last_atom = Some(atom);
true
}
fn apply_portable_repetition(
frame: &mut PortablePatternFrame,
repetition: PortableRepetition,
) -> bool {
let Some(atom) = frame.last_atom.as_mut() else {
return false;
};
if atom.is_quantified || repetition.maximum == Some(0) {
return false;
}
if atom.is_group
&& (atom.can_match_empty || atom.contains_variable_repetition || atom.contains_alternation)
{
return false;
}
let has_variable_extent = repetition.has_variable_extent();
if has_variable_extent && frame.branch_has_variable_shape {
return false;
}
atom.is_quantified = true;
atom.has_variable_shape |= has_variable_extent;
atom.can_match_empty = repetition.minimum == 0 || atom.can_match_empty;
frame.sequence_can_match_empty = atom.prefix_can_match_empty && atom.can_match_empty;
frame.branch_has_variable_shape |= has_variable_extent;
frame.contains_variable_repetition |= has_variable_extent;
true
}
fn parse_portable_repetition(value: &str) -> Option<PortableRepetition> {
let (minimum, maximum) = if let Some((minimum, maximum)) = value.split_once(',') {
if maximum.contains(',') {
return None;
}
let minimum = parse_portable_repetition_bound(minimum)?;
let maximum = if maximum.is_empty() {
None
} else {
Some(parse_portable_repetition_bound(maximum)?)
};
(minimum, maximum)
} else {
let minimum = parse_portable_repetition_bound(value)?;
(minimum, Some(minimum))
};
if maximum.is_some_and(|maximum| maximum < minimum) {
return None;
}
Some(PortableRepetition { minimum, maximum })
}
fn parse_portable_repetition_bound(value: &str) -> Option<u32> {
if value.is_empty()
|| value.len() > 1 && value.starts_with('0')
|| !value.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
value
.parse::<u32>()
.ok()
.filter(|value| *value <= MAX_PORTABLE_REPETITION)
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the finite, non-negative, integral, safe-integer checks make this conversion exact"
)]
fn non_negative_safe_schema_integer(value: &Value) -> Option<u64> {
let number = value.as_f64()?;
(number.is_finite()
&& number >= 0.0
&& number.fract() == 0.0
&& number <= 9_007_199_254_740_991.0)
.then_some(number as u64)
}
fn unsupported_schema(source_path: &Path, detail: impl Into<String>) -> CodegenError {
CodegenError::UnsupportedSchema {
path: source_path.to_path_buf(),
detail: detail.into(),
}
}
#[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(())
}
fn generation_input(path: &Path) -> Result<(GeneratedMetadata, ContractIr), 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((metadata, contract))
}
/// Generates one selected language projection from a Descriptor source.
pub fn generate_projection(
path: &Path,
language: ProjectionLanguage,
) -> Result<GeneratedProjection, CodegenError> {
let (metadata, contract) = generation_input(path)?;
let source = match language {
ProjectionLanguage::Rust => generate_rust(&contract),
ProjectionLanguage::TypeScript => generate_typescript(&contract),
ProjectionLanguage::Wit => wit::generate_wit(&contract)?,
ProjectionLanguage::RustRuntime => generate_rust_runtime(&contract)?,
};
Ok(GeneratedProjection {
metadata,
language,
source,
})
}
/// Generates both language artifacts from one Descriptor source.
pub fn generate(path: &Path) -> Result<GeneratedArtifacts, CodegenError> {
let (metadata, contract) = generation_input(path)?;
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(())
}
/// Writes one selected language projection to a checked-in path.
pub fn write_projection(
descriptor_path: &Path,
language: ProjectionLanguage,
output_path: &Path,
) -> Result<(), CodegenError> {
let projection = generate_projection(descriptor_path, language)?;
write_artifact(output_path, &projection.source)
}
/// 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(())
}
/// Fails when one selected language projection is not exactly reproducible.
pub fn check_projection(
descriptor_path: &Path,
language: ProjectionLanguage,
output_path: &Path,
) -> Result<(), CodegenError> {
let projection = generate_projection(descriptor_path, language)?;
check_artifact(output_path, &projection.source)
}
/// 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_portable_value(value, "$".to_owned())?;
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")
&& !portable_json_semantic_equal(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| portable_json_semantic_equal(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);
};
let length = u64::try_from(values.len()).unwrap_or(u64::MAX);
if let Some(minimum) = schema
.get("minItems")
.and_then(non_negative_safe_schema_integer)
&& length < minimum
{
return invalid_wire_value(path, "array has fewer items than minItems", source_path);
}
if let Some(maximum) = schema
.get("maxItems")
.and_then(non_negative_safe_schema_integer)
&& length > maximum
{
return invalid_wire_value(path, "array has more items than maxItems", source_path);
}
if schema.get("uniqueItems") == Some(&Value::Bool(true)) {
let mut fingerprints = HashSet::with_capacity(values.len());
for value in values {
if !fingerprints.insert(portable_json_fingerprint(value)) {
return invalid_wire_value(path, "array items must be unique", 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)?;
}
}
Ok(())
}
fn portable_json_fingerprint(value: &Value) -> String {
let mut output = String::new();
write_portable_json_fingerprint(value, &mut output);
output
}
fn portable_json_semantic_equal(left: &Value, right: &Value) -> bool {
portable_json_fingerprint(left) == portable_json_fingerprint(right)
}
fn write_portable_json_fingerprint(value: &Value, output: &mut String) {
match value {
Value::Null => output.push('n'),
Value::Bool(value) => output.push_str(if *value { "b1" } else { "b0" }),
Value::Number(value) => {
let number = value
.as_f64()
.expect("portable JSON numbers are representable as f64");
let bits = if number == 0.0 { 0 } else { number.to_bits() };
write!(output, "d{bits:016x};").expect("writing to a String cannot fail");
}
Value::String(value) => {
write!(output, "s{}:", value.len()).expect("writing to a String cannot fail");
output.push_str(value);
}
Value::Array(values) => {
write!(output, "a{}[", values.len()).expect("writing to a String cannot fail");
for value in values {
write_portable_json_fingerprint(value, output);
}
output.push(']');
}
Value::Object(values) => {
let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_unstable_by_key(|(key, _)| *key);
write!(output, "o{}{{", entries.len()).expect("writing to a String cannot fail");
for (key, value) in entries {
write!(output, "k{}:", key.len()).expect("writing to a String cannot fail");
output.push_str(key);
write_portable_json_fingerprint(value, output);
}
output.push('}');
}
}
}
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)?;
if let Some(pattern) = schema.get("pattern").and_then(Value::as_str) {
let Ok(pattern) = compile_portable_pattern(pattern) else {
return invalid_wire_value(path, "Schema pattern could not be compiled", source_path);
};
if !pattern.is_match(value) {
return invalid_wire_value(path, "string does not match pattern", source_path);
}
}
let length = u64::try_from(value.chars().count()).unwrap_or(u64::MAX);
if let Some(minimum) = schema
.get("minLength")
.and_then(non_negative_safe_schema_integer)
&& length < minimum
{
return invalid_wire_value(path, "string is shorter than minLength", source_path);
}
if let Some(maximum) = schema
.get("maxLength")
.and_then(non_negative_safe_schema_integer)
&& 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_component = false;
let mut last_date_order = None;
let mut last_time_order = None;
let mut saw_week = false;
let mut saw_other_date_unit = 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 digit_start = index;
while bytes.get(index).is_some_and(u8::is_ascii_digit) {
index += 1;
}
if index == digit_start {
return false;
}
let mut fractional = false;
if bytes.get(index) == Some(&b'.') {
fractional = true;
index += 1;
let fraction_start = index;
while bytes.get(index).is_some_and(u8::is_ascii_digit) {
index += 1;
}
if index == fraction_start {
return false;
}
}
let Some(&unit) = bytes.get(index) else {
return false;
};
if in_time {
let order = match unit {
b'H' => 0,
b'M' => 1,
b'S' => 2,
_ => return false,
};
if last_time_order.is_some_and(|previous| order <= previous) {
return false;
}
last_time_order = Some(order);
saw_time_component = true;
} else {
let order = match unit {
b'Y' => 0,
b'M' => 1,
b'W' => 2,
b'D' => 3,
_ => return false,
};
if last_date_order.is_some_and(|previous| order <= previous) {
return false;
}
last_date_order = Some(order);
if unit == b'W' {
if saw_other_date_unit || index + 1 != bytes.len() {
return false;
}
saw_week = true;
} else {
if saw_week {
return false;
}
saw_other_date_unit = true;
}
}
saw_component = true;
index += 1;
if fractional && index != bytes.len() {
return false;
}
}
saw_component && (!in_time || saw_time_component)
}
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"
| "PluginDependencies"
| "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 = \"lenso_contract_runtime::serde::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 = \"lenso_contract_runtime::serde::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::RawJson => "RawJson".to_owned(),
TypeIr::Enum { name, values } => {
self.enum_type(name.as_deref().unwrap_or(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 {
name,
fields,
additional,
} => {
let nested_name = name.as_deref().unwrap_or(nested_name);
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 | TypeIr::RawJson => "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 {
name,
fields,
additional,
} => {
let nested_name = name.as_deref().unwrap_or(nested_name);
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))
}
}
}
}
#[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 native_support_name = format!("__LensoNativeSupport{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 provider_lowering_methods = Vec::new();
let mut provider_result_conversions = 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 many_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" => {
let provider_method = rust_field_name(&operation.name);
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 fn invoke_native(endpoint: &dyn NativeRequestEndpoint, operation: &str, request: Self::Request, context: InvocationContext) -> NativeRequestFuture<Self> {{\n if operation != {operation_const}_OPERATION {{\n return lenso_kernel::invoke_typed_or_erased_native_request::<Self>(endpoint, operation, request, context);\n }}\n let Some(typed_endpoint) = endpoint\n .typed_endpoint()\n .and_then(|endpoint| endpoint.downcast_ref::<{capability_name}RequestEndpoint>())\n else {{\n return lenso_kernel::invoke_typed_or_erased_native_request::<Self>(endpoint, operation, request, context);\n }};\n Rc::clone(&typed_endpoint.provider).{provider_method}(context, request)\n }}\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}) -> NativeRequestFuture<{marker_name}>;",
rust_field_name(&operation.name),
));
let field = rust_field_name(&operation.name);
let conversion = format!("__LensoInto{capability_name}{operation_name}Result");
provider_result_conversions.push(format!(
"#[doc(hidden)]\npub trait {conversion} {{\n fn __lenso_into_result(self) -> Result<Result<{response_type}, {error_name}>, RuntimeFailure>;\n}}\nimpl {conversion} for Result<{response_type}, {error_name}> {{\n fn __lenso_into_result(self) -> Result<Result<{response_type}, {error_name}>, RuntimeFailure> {{ Ok(self) }}\n}}\nimpl {conversion} for Result<Result<{response_type}, {error_name}>, RuntimeFailure> {{\n fn __lenso_into_result(self) -> Result<Result<{response_type}, {error_name}>, RuntimeFailure> {{ self }}\n}}\nimpl {conversion} for Result<{response_type}, lenso_plugin_authoring::PluginError<{error_name}, RuntimeFailure>> {{\n fn __lenso_into_result(self) -> Result<Result<{response_type}, {error_name}>, RuntimeFailure> {{\n match self {{\n Ok(value) => Ok(Ok(value)),\n Err(lenso_plugin_authoring::PluginError::Domain(error)) => Ok(Err(error)),\n Err(lenso_plugin_authoring::PluginError::Runtime(error)) => Err(error),\n }}\n }}\n}}\nimpl {conversion} for Result<{response_type}, {invocation_error_name}> {{\n fn __lenso_into_result(self) -> Result<Result<{response_type}, {error_name}>, RuntimeFailure> {{\n match self {{\n Ok(value) => Ok(Ok(value)),\n Err({invocation_error_name}::Domain(error)) => Ok(Err(error)),\n Err({invocation_error_name}::Runtime(error)) => Err(error),\n }}\n }}\n}}\n"
));
provider_lowering_methods.push(format!(
" fn {field}(&self, context: {native_support_name}::InvocationContext, request: $crate::{request_type}) -> {native_support_name}::NativeRequestFuture<$crate::{marker_name}> {{\n let plugin = self.clone();\n ::std::boxed::Box::pin(async move {{\n let result = <$plugin>::{field}(&plugin, context, request).await;\n $crate::{conversion}::__lenso_into_result(result)\n }})\n }}"
));
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 invocation = Rc::clone(&self.provider).{}(context, *request);\n Box::pin(async move {{\n invocation.await.map(|result| {{\n result\n .map(|value| Box::new(value) as Box<dyn std::any::Any>)\n .map_err(|error| Box::new(error) as Box<dyn std::any::Any>)\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}>()?,"
));
many_client_initializers.push(format!(
" {field}: binding.handle().ok_or(RuntimeFailure::Unavailable {{ capability: CAPABILITY_ID }})?.typed::<{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)
));
let field = rust_field_name(&operation.name);
let conversion = format!("__LensoInto{capability_name}{operation_name}StreamResult");
provider_result_conversions.push(format!(
"#[doc(hidden)]\npub trait {conversion} {{\n fn __lenso_into_result(self) -> Result<Box<dyn NativeStreamSession>, {invocation_error_name}>;\n}}\nimpl<S> {conversion} for Result<S, {error_name}>\nwhere\n S: NativeStreamSession + 'static,\n{{\n fn __lenso_into_result(self) -> Result<Box<dyn NativeStreamSession>, {invocation_error_name}> {{\n self.map(|stream| Box::new(stream) as Box<dyn NativeStreamSession>)\n .map_err({invocation_error_name}::Domain)\n }}\n}}\nimpl<S> {conversion} for Result<S, lenso_plugin_authoring::PluginError<{error_name}, RuntimeFailure>>\nwhere\n S: NativeStreamSession + 'static,\n{{\n fn __lenso_into_result(self) -> Result<Box<dyn NativeStreamSession>, {invocation_error_name}> {{\n match self {{\n Ok(stream) => Ok(Box::new(stream) as Box<dyn NativeStreamSession>),\n Err(lenso_plugin_authoring::PluginError::Domain(error)) => Err({invocation_error_name}::Domain(error)),\n Err(lenso_plugin_authoring::PluginError::Runtime(error)) => Err({invocation_error_name}::Runtime(error)),\n }}\n }}\n}}\nimpl<S> {conversion} for Result<S, {invocation_error_name}>\nwhere\n S: NativeStreamSession + 'static,\n{{\n fn __lenso_into_result(self) -> Result<Box<dyn NativeStreamSession>, {invocation_error_name}> {{\n self.map(|stream| Box::new(stream) as Box<dyn NativeStreamSession>)\n }}\n}}\n"
));
provider_lowering_methods.push(format!(
" fn {field}(&self, context: {native_support_name}::InvocationContext, request: $crate::{request_type}) -> {native_support_name}::LocalBoxFuture<'static, Result<Box<dyn {native_support_name}::NativeStreamSession>, $crate::{invocation_error_name}>> {{\n let plugin = self.clone();\n ::std::boxed::Box::pin(async move {{\n let result = <$plugin>::{field}(&plugin, context, request).await;\n $crate::{conversion}::__lenso_into_result(result)\n }})\n }}"
));
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}>()?,"
));
many_client_initializers.push(format!(
" {field}: binding.stream_handle().ok_or(RuntimeFailure::Unavailable {{ capability: CAPABILITY_ID }})?.typed::<{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}) -> LocalBoxFuture<'static, Result<(), RuntimeFailure>>;",
rust_field_name(&operation.name)
));
let field = rust_field_name(&operation.name);
let conversion = format!("__LensoInto{capability_name}{operation_name}EventResult");
provider_result_conversions.push(format!(
"#[doc(hidden)]\npub trait {conversion} {{\n fn __lenso_into_result(self) -> Result<(), RuntimeFailure>;\n}}\nimpl {conversion} for () {{\n fn __lenso_into_result(self) -> Result<(), RuntimeFailure> {{ Ok(()) }}\n}}\nimpl {conversion} for Result<(), RuntimeFailure> {{\n fn __lenso_into_result(self) -> Result<(), RuntimeFailure> {{ self }}\n}}\n"
));
provider_lowering_methods.push(format!(
" fn {field}(&self, context: {native_support_name}::InvocationContext, event: $crate::{request_type}) -> {native_support_name}::LocalBoxFuture<'static, Result<(), {native_support_name}::RuntimeFailure>> {{\n let plugin = self.clone();\n ::std::boxed::Box::pin(async move {{\n let result = <$plugin>::{field}(&plugin, context, event).await;\n $crate::{conversion}::__lenso_into_result(result)\n }})\n }}"
));
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 Rc::clone(&self.provider).{}(context, *event)\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}>()?,"
));
many_client_initializers.push(format!(
" {field}: binding.event_handle().ok_or(RuntimeFailure::Unavailable {{ capability: CAPABILITY_ID }})?.typed::<{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 typed_endpoint(&self) -> Option<&dyn std::any::Any> {{ Some(&self.request_endpoint) }}\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", "PluginDependencies", "RuntimeFailure"];
if has_request_operations {
kernel_imports.extend([
"NativeRequestFuture",
"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",
]);
}
kernel_imports.sort_unstable();
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");
output.push_str(
"use lenso_plugin_authoring::{BoundCapabilityClient, CapabilityClient, CapabilityClientMany};\n",
);
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");
let capability_macro_name = snake_case(&capability_name);
let client_macro_name = snake_case(&format!("{capability_name}Client"));
let operations = contract
.operations
.iter()
.map(|operation| Value::String(operation.name.clone()))
.collect::<Vec<_>>();
let operation_kinds = contract
.operations
.iter()
.filter(|operation| operation.interaction != "request")
.map(|operation| {
(
operation.name.clone(),
Value::String(operation.interaction.clone()),
)
})
.collect::<Map<_, _>>();
let provided_fragment = canonical_json(&serde_json::json!({
"capability_id": contract.capability_id,
"descriptor_version": contract.version,
"operations": operations,
"operation_kinds": operation_kinds,
"default_admission": {
"queue_capacity": 0,
"max_concurrency": 1
},
"operation_admissions": {},
"event_admission": null,
"cross_lane_transfer": contract.cross_lane_transfer
}));
let required_fragment = canonical_json(&serde_json::json!({
"capability_id": contract.capability_id,
"descriptor_version": contract.version,
"cardinality": "one"
}));
let required_many_fragment = canonical_json(&serde_json::json!({
"capability_id": contract.capability_id,
"descriptor_version": contract.version,
"cardinality": "many"
}));
writeln!(
output,
"#[doc(hidden)]\n#[macro_export]\nmacro_rules! __lenso_provided_{capability_macro_name} {{ () => {{ {} }}; }}\n\n#[doc(hidden)]\n#[macro_export]\nmacro_rules! __lenso_required_{client_macro_name} {{ () => {{ {} }}; }}\n\n#[doc(hidden)]\n#[macro_export]\nmacro_rules! __lenso_required_many_{client_macro_name} {{ () => {{ {} }}; }}\n",
quote_string(&provided_fragment),
quote_string(&required_fragment),
quote_string(&required_many_fragment),
)
.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");
}
writeln!(
output,
"\npub use lenso_contract_runtime::{{{}}};\nuse lenso_contract_runtime::{{decode_portable_json, encode_portable_json}};\n",
rust_runtime_types(contract).join(", ")
)
.expect("writing to a String cannot fail");
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');
}
for conversion in provider_result_conversions {
output.push_str(&conversion);
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");
writeln!(
output,
"#[doc(hidden)]\n#[macro_export]\nmacro_rules! __lenso_native_lower_{capability_macro_name} {{\n ($plugin:ty, $support:path) => {{\n use $support as {native_support_name};\n impl $crate::{capability_name}Provider for $plugin {{\n{}\n }}\n }};\n}}\n",
provider_lowering_methods.join("\n")
)
.expect("writing to a String cannot fail");
if has_request_operations {
write!(
output,
"#[derive(Debug)]\nstruct {capability_name}RequestEndpoint {{ provider: Rc<dyn {capability_name}Provider> }}\n\n#[derive(Debug)]\npub struct {capability_name}Endpoint<P: {capability_name}Provider> {{ provider: Rc<P>, request_endpoint: {capability_name}RequestEndpoint }}\nimpl<P: {capability_name}Provider> {capability_name}Endpoint<P> {{\n pub fn new(provider: P) -> Self {{\n let provider = Rc::new(provider);\n let request_provider: Rc<dyn {capability_name}Provider> = provider.clone();\n Self {{ provider, request_endpoint: {capability_name}RequestEndpoint {{ provider: request_provider }} }}\n }}\n}}\n\n"
)
.expect("writing to a String cannot fail");
} else {
write!(
output,
"#[derive(Debug)]\npub struct {capability_name}Endpoint<P: {capability_name}Provider> {{ 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 request_endpoint_value = has_request_operations.then_some(
"endpoint.clone() as ::std::rc::Rc<dyn __LensoNativeSupport::NativeRequestEndpoint>",
);
let stream_endpoint_value = has_stream_operations.then_some(
"endpoint.clone() as ::std::rc::Rc<dyn __LensoNativeSupport::NativeStreamEndpoint>",
);
let event_endpoint_value = has_event_operations
.then_some("endpoint as ::std::rc::Rc<dyn __LensoNativeSupport::NativeEventEndpoint>");
writeln!(
output,
"#[doc(hidden)]\n#[macro_export]\nmacro_rules! __lenso_native_endpoints_{capability_macro_name} {{\n ($provider:expr, $support:path) => {{{{\n use $support as __LensoNativeSupport;\n let endpoint = ::std::rc::Rc::new($crate::{capability_name}Endpoint::new($provider));\n (\n vec![{}],\n vec![{}],\n vec![{}],\n )\n }}}};\n}}\n\n#[doc(hidden)]\n#[macro_export]\nmacro_rules! __lenso_native_provide_{capability_macro_name} {{\n ($provider:expr, $lifecycle:expr, $support:path) => {{{{\n use $support as __LensoNativeSupport;\n let (request_endpoints, stream_endpoints, event_endpoints) =\n $crate::__lenso_native_endpoints_{capability_macro_name}!($provider, $support);\n __LensoNativeSupport::NativePluginInstance::with_all_endpoints(\n request_endpoints,\n stream_endpoints,\n event_endpoints,\n $lifecycle,\n )\n }}}};\n}}\n",
request_endpoint_value.unwrap_or_default(),
stream_endpoint_value.unwrap_or_default(),
event_endpoint_value.unwrap_or_default(),
)
.expect("writing to a String cannot fail");
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: &PluginDependencies) -> Result<Self, RuntimeFailure> {{\n <Self as CapabilityClient>::from_dependencies(dependencies)\n }}\n\n{}\n}}\n\nimpl CapabilityClient for {capability_name}Client {{\n type Dependencies = PluginDependencies;\n type Error = RuntimeFailure;\n\n const CAPABILITY_ID: &'static str = CAPABILITY_ID;\n const DESCRIPTOR_VERSION: &'static str = DESCRIPTOR_VERSION;\n\n fn from_dependencies(dependencies: &PluginDependencies) -> Result<Self, RuntimeFailure> {{\n Ok(Self {{\n{}\n }})\n }}\n\n fn already_connected() -> RuntimeFailure {{\n RuntimeFailure::PluginFailure {{\n detail: format!(\"Capability Port {{CAPABILITY_ID}} was connected more than once\"),\n }}\n }}\n}}\n\nimpl CapabilityClientMany for {capability_name}Client {{\n fn many_from_dependencies(\n dependencies: &PluginDependencies,\n ) -> Result<Vec<BoundCapabilityClient<Self>>, RuntimeFailure> {{\n dependencies\n .bindings()\n .iter()\n .filter(|binding| binding.capability_id() == CAPABILITY_ID)\n .map(|binding| {{\n Ok(BoundCapabilityClient::new(\n binding.provider_instance(),\n Self {{\n{}\n }},\n ))\n }})\n .collect()\n }}\n}}\n\n",
client_fields.join("\n"),
new_method,
client_methods.join("\n\n"),
client_initializers.join("\n"),
many_client_initializers.join("\n")
)
.expect("writing to a String cannot fail");
for error in invocation_errors {
output.push_str(&error);
}
format!("{}\n", output.trim_end())
}
fn rust_runtime_types(contract: &ContractIr) -> Vec<&'static str> {
let mut types = BTreeSet::from(["UnknownDomainError"]);
for operation in &contract.operations {
collect_rust_runtime_types(&operation.request, &mut types);
collect_rust_runtime_types(&operation.response, &mut types);
for error in &operation.domain_errors {
if let Some(payload) = &error.payload {
if !error.payload_required && payload.is_nullable() {
types.insert("OptionalValue");
}
collect_rust_runtime_types(payload, &mut types);
}
}
}
types.into_iter().collect()
}
fn collect_rust_runtime_types(ty: &TypeIr, types: &mut BTreeSet<&'static str>) {
match ty {
TypeIr::Int64 => {
types.insert("Int64");
}
TypeIr::Uint64 => {
types.insert("Uint64");
}
TypeIr::Bytes => {
types.insert("Bytes");
}
TypeIr::Timestamp => {
types.insert("Timestamp");
}
TypeIr::Duration => {
types.insert("Duration");
}
TypeIr::RawJson => {
types.insert("RawJson");
}
TypeIr::Array(item) | TypeIr::Nullable(item) => {
collect_rust_runtime_types(item, types);
}
TypeIr::Object {
fields, additional, ..
} => {
for field in fields {
if !field.required && field.ty.is_nullable() {
types.insert("OptionalValue");
}
collect_rust_runtime_types(&field.ty, types);
}
if let ObjectAdditionalIr::Typed(value) = additional {
collect_rust_runtime_types(value, types);
}
}
TypeIr::Any
| TypeIr::String
| TypeIr::Integer
| TypeIr::Number
| TypeIr::Boolean
| TypeIr::Null
| TypeIr::Enum { .. } => {}
}
}
#[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
}
#[allow(clippy::too_many_lines)]
fn generate_rust_runtime(contract: &ContractIr) -> Result<String, CodegenError> {
let capability_name = pascal_case(
contract
.capability_id
.split('@')
.next()
.and_then(|identity| identity.rsplit('.').next())
.unwrap_or("Capability"),
);
let codec_name = format!("{capability_name}JsonCodec");
let guest_client_name = format!("{capability_name}GuestClient");
let mut types = RustTypes::new();
let mut request_operations = Vec::new();
let mut stream_operations = Vec::new();
let mut encode_arms = Vec::new();
let mut response_arms = Vec::new();
let mut error_arms = Vec::new();
let mut host_request_arms = Vec::new();
let mut stream_open_arms = Vec::new();
let mut stream_encode_arms = Vec::new();
let mut stream_decode_arms = Vec::new();
let mut stream_error_arms = Vec::new();
let mut host_stream_arms = Vec::new();
let mut guest_methods = Vec::new();
for operation in &contract.operations {
if operation.interaction == "event" {
return Err(CodegenError::UnsupportedInteraction {
operation: operation.name.clone(),
interaction: operation.interaction.clone(),
});
}
let operation_name = pascal_case(&operation.name);
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, &format!("{operation_name}Request"));
let response_type =
types.type_for(&operation.response, &format!("{operation_name}Response"));
let error_type = format!("{operation_name}Error");
let operation_const = format!("{}_OPERATION", screaming_snake_case(&operation.name));
let method_name = rust_field_name(&operation.name);
match operation.interaction.as_str() {
"request" => {
request_operations.push(operation_const.clone());
encode_arms.push(format!(
" {operation_const} => {{\n let value = request.downcast_ref::<{request_type}>().ok_or_else(runtime_codec_protocol_failure)?;\n serde_json::to_value(value).map_err(|_| runtime_codec_protocol_failure())\n }}"
));
response_arms.push(format!(
" {operation_const} => serde_json::from_value::<{response_type}>(value)\n .map(|value| Box::new(value) as Box<dyn std::any::Any>)\n .map_err(|_| runtime_codec_protocol_failure()),"
));
error_arms.push(format!(
" {operation_const} => serde_json::from_value::<{error_type}>(value)\n .map(|value| Box::new(value) as Box<dyn std::any::Any>)\n .map_err(|_| runtime_codec_protocol_failure()),"
));
host_request_arms.push(format!(
" {operation_const} => {{\n let request = serde_json::from_value::<{request_type}>(request).map_err(|_| runtime_codec_protocol_failure());\n Box::pin(async move {{\n let request = request?;\n let handle = dependency.typed::<{marker_name}>()?;\n match handle.invoke_with_context({operation_const}, context, request).await? {{\n Ok(response) => serde_json::to_value(response)\n .map(lenso_runtime_codec::JsonInvocationOutcome::Success)\n .map_err(|_| runtime_codec_protocol_failure()),\n Err(error) => serde_json::to_value(error)\n .map(lenso_runtime_codec::JsonInvocationOutcome::DomainError)\n .map_err(|_| runtime_codec_protocol_failure()),\n }}\n }})\n }}"
));
guest_methods.push(format!(
" pub fn {method_name}(&self, request: &{request_type}) -> Result<{response_type}, lenso_guest_sdk::GuestError<{error_type}>> {{\n self.capability.request({operation_const}, request)\n }}"
));
}
"stream" => {
stream_operations.push(operation_const.clone());
stream_open_arms.push(format!(
" {operation_const} => {{\n let value = request.downcast_ref::<{request_type}>().ok_or_else(runtime_codec_protocol_failure)?;\n serde_json::to_value(value).map_err(|_| runtime_codec_protocol_failure())\n }}"
));
stream_encode_arms.push(format!(
" {operation_const} => {{\n let value = message.downcast_ref::<{response_type}>().ok_or_else(runtime_codec_protocol_failure)?;\n serde_json::to_value(value).map_err(|_| runtime_codec_protocol_failure())\n }}"
));
stream_decode_arms.push(format!(
" {operation_const} => serde_json::from_value::<{response_type}>(value)\n .map(|value| Box::new(value) as Box<dyn std::any::Any>)\n .map_err(|_| runtime_codec_protocol_failure()),"
));
stream_error_arms.push(format!(
" {operation_const} => serde_json::from_value::<{error_type}>(value)\n .map(|value| Box::new(value) as Box<dyn std::any::Any>)\n .map_err(|_| runtime_codec_protocol_failure()),"
));
host_stream_arms.push(format!(
" {operation_const} => {{\n let request = serde_json::from_value::<{request_type}>(request).map_err(|_| runtime_codec_protocol_failure());\n Box::pin(async move {{\n let request = request?;\n let handle = dependency.typed::<{marker_name}>()?;\n match handle.open_with_context({operation_const}, context, request).await? {{\n Ok(stream) => Ok(Ok(lenso_runtime_codec::json_host_stream::<{marker_name}>(\n stream,\n |value| serde_json::from_value::<{response_type}>(value).map_err(|_| runtime_codec_protocol_failure()),\n |message| serde_json::to_value(message).map_err(|_| runtime_codec_protocol_failure()),\n |error| serde_json::to_value(error).map_err(|_| runtime_codec_protocol_failure()),\n ))),\n Err(error) => serde_json::to_value(error)\n .map(Err)\n .map_err(|_| runtime_codec_protocol_failure()),\n }}\n }})\n }}"
));
guest_methods.push(format!(
" pub fn {method_name}(&self, request: &{request_type}) -> Result<lenso_guest_sdk::GuestStream<H, {response_type}, {error_type}>, lenso_guest_sdk::GuestError<{error_type}>> {{\n self.capability.open_stream({operation_const}, request)\n }}"
));
}
_ => unreachable!("Descriptor validation restricts interactions"),
}
}
let encode_dispatch = runtime_codec_dispatch(&encode_arms);
let response_dispatch = runtime_codec_dispatch(&response_arms);
let error_dispatch = runtime_codec_dispatch(&error_arms);
let host_request_dispatch = runtime_codec_host_dispatch(&host_request_arms);
let stream_open_dispatch = runtime_codec_dispatch(&stream_open_arms);
let stream_encode_dispatch = runtime_codec_dispatch(&stream_encode_arms);
let stream_decode_dispatch = runtime_codec_dispatch(&stream_decode_arms);
let stream_error_dispatch = runtime_codec_dispatch(&stream_error_arms);
let host_stream_dispatch = runtime_codec_host_dispatch(&host_stream_arms);
let request_parameter = if request_operations.is_empty() {
"_request"
} else {
"request"
};
let request_value_parameter = if request_operations.is_empty() {
"_value"
} else {
"value"
};
let stream_request_parameter = if stream_operations.is_empty() {
"_request"
} else {
"request"
};
let stream_message_parameter = if stream_operations.is_empty() {
"_message"
} else {
"message"
};
let stream_value_parameter = if stream_operations.is_empty() {
"_value"
} else {
"value"
};
let (host_request_dependency_parameter, host_request_parameter, host_request_context_parameter) =
if request_operations.is_empty() {
("_dependency", "_request", "_context")
} else {
("dependency", "request", "context")
};
let (host_stream_dependency_parameter, host_stream_parameter, host_stream_context_parameter) =
if stream_operations.is_empty() {
("_dependency", "_request", "_context")
} else {
("dependency", "request", "context")
};
let mut output = generate_rust(contract);
write!(
output,
"\n#[derive(Clone, Copy, Debug)]\npub struct {guest_client_name}<'a, H: lenso_guest_sdk::HostImports> {{\n capability: lenso_guest_sdk::GuestCapability<'a, H>,\n}}\n\nimpl<'a, H: lenso_guest_sdk::HostImports> {guest_client_name}<'a, H> {{\n pub fn from_context(context: &'a lenso_guest_sdk::GuestContext<H>) -> Result<Self, lenso_guest_sdk::GuestError<serde_json::Value>> {{\n context\n .require(CAPABILITY_ID, DESCRIPTOR_VERSION, &[{}], &[{}])\n .map(|capability| Self {{ capability }})\n }}\n\n{}\n}}\n",
request_operations.join(", "),
stream_operations.join(", "),
guest_methods.join("\n\n"),
)
.expect("writing generated Rust to a String cannot fail");
write!(
output,
"\n#[derive(Debug, Default)]\npub struct {codec_name};\n\nimpl lenso_runtime_codec::JsonCapabilityCodec for {codec_name} {{\n fn capability_id(&self) -> &'static str {{ CAPABILITY_ID }}\n\n fn descriptor_version(&self) -> &'static str {{ DESCRIPTOR_VERSION }}\n\n fn request_operations(&self) -> &'static [&'static str] {{ &[{}] }}\n fn stream_operations(&self) -> &'static [&'static str] {{ &[{}] }}\n\n fn encode_request(&self, operation: &str, {request_parameter}: &dyn std::any::Any) -> Result<serde_json::Value, RuntimeFailure> {{\n{encode_dispatch}\n }}\n\n fn decode_response(&self, operation: &str, {request_value_parameter}: serde_json::Value) -> Result<Box<dyn std::any::Any>, RuntimeFailure> {{\n{response_dispatch}\n }}\n\n fn decode_domain_error(&self, operation: &str, {request_value_parameter}: serde_json::Value) -> Result<Box<dyn std::any::Any>, RuntimeFailure> {{\n{error_dispatch}\n }}\n\n fn encode_stream_open(&self, operation: &str, {stream_request_parameter}: &dyn std::any::Any) -> Result<serde_json::Value, RuntimeFailure> {{\n{stream_open_dispatch}\n }}\n\n fn encode_stream_message(&self, operation: &str, {stream_message_parameter}: &dyn std::any::Any) -> Result<serde_json::Value, RuntimeFailure> {{\n{stream_encode_dispatch}\n }}\n\n fn decode_stream_message(&self, operation: &str, {stream_value_parameter}: serde_json::Value) -> Result<Box<dyn std::any::Any>, RuntimeFailure> {{\n{stream_decode_dispatch}\n }}\n\n fn decode_stream_domain_error(&self, operation: &str, {stream_value_parameter}: serde_json::Value) -> Result<Box<dyn std::any::Any>, RuntimeFailure> {{\n{stream_error_dispatch}\n }}\n\n fn invoke_host_request(&self, {host_request_dependency_parameter}: lenso_kernel::PluginDependencyHandle, operation: String, {host_request_parameter}: serde_json::Value, {host_request_context_parameter}: InvocationContext) -> lenso_runtime_codec::JsonHostRequestFuture {{\n{host_request_dispatch}\n }}\n\n fn open_host_stream(&self, {host_stream_dependency_parameter}: lenso_kernel::PluginStreamDependencyHandle, operation: String, {host_stream_parameter}: serde_json::Value, {host_stream_context_parameter}: InvocationContext) -> lenso_runtime_codec::JsonHostStreamOpenFuture {{\n{host_stream_dispatch}\n }}\n}}\n\nfn runtime_codec_protocol_failure() -> RuntimeFailure {{ RuntimeFailure::ProtocolViolation {{ capability: CAPABILITY_ID }} }}\n\nfn runtime_codec_unknown_operation(operation: &str) -> RuntimeFailure {{\n RuntimeFailure::UnknownOperation {{ capability: CAPABILITY_ID, operation: operation.to_owned() }}\n}}\n",
request_operations.join(", "),
stream_operations.join(", "),
)
.expect("writing generated Rust to a String cannot fail");
Ok(output)
}
fn runtime_codec_host_dispatch(arms: &[String]) -> String {
if arms.is_empty() {
" Box::pin(std::future::ready(Err(runtime_codec_unknown_operation(&operation))))"
.to_owned()
} else {
let arms = arms
.iter()
.map(|arm| arm.trim_end_matches(','))
.collect::<Vec<_>>()
.join(",\n");
format!(
" match operation.as_str() {{\n{arms},\n _ => Box::pin(std::future::ready(Err(runtime_codec_unknown_operation(&operation)))),\n }}"
)
}
}
fn runtime_codec_dispatch(arms: &[String]) -> String {
if arms.is_empty() {
" Err(runtime_codec_unknown_operation(operation))".to_owned()
} else {
let arms = arms
.iter()
.map(|arm| arm.trim_end_matches(','))
.collect::<Vec<_>>()
.join(",\n");
format!(
" match operation {{\n{arms},\n _ => Err(runtime_codec_unknown_operation(operation)),\n }}"
)
}
}
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> {{ encode_portable_json(value) }}\npub fn decode_{stem}_request(wire: &str) -> Result<{request_type}, serde_json::Error> {{ decode_portable_json(wire) }}\npub fn encode_{stem}_response(value: &{response_type}) -> Result<String, serde_json::Error> {{ encode_portable_json(value) }}\npub fn decode_{stem}_response(wire: &str) -> Result<{response_type}, serde_json::Error> {{ decode_portable_json(wire) }}\npub fn encode_{stem}_error(value: &{error_type}) -> Result<String, serde_json::Error> {{ encode_portable_json(value) }}\npub fn decode_{stem}_error(wire: &str) -> Result<{error_type}, serde_json::Error> {{ decode_portable_json(wire) }}\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> {{ encode_portable_json(value) }}\npub fn decode_{stem}_event(wire: &str) -> Result<{event_type}, serde_json::Error> {{ decode_portable_json(wire) }}\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 known_strings = variants
.iter()
.filter(|variant| !variant.structured)
.map(|variant| quote_string(&variant.code))
.collect::<Vec<_>>()
.join(", ");
format!(
"export function encode{stem}Request(value: {request_type}): string {{ return lensoContractRuntime.encodePortableJson(value, \"request\"); }}\nexport function decode{stem}Request(wire: string): {request_type} {{ return lensoContractRuntime.decodePortableJson<{request_type}>(wire); }}\nexport function encode{stem}Response(value: {response_type}): string {{ return lensoContractRuntime.encodePortableJson(value, \"response\"); }}\nexport function decode{stem}Response(wire: string): {response_type} {{ return lensoContractRuntime.decodePortableJson<{response_type}>(wire); }}\nexport function encode{stem}Error(value: {error_type}): string {{ return lensoContractRuntime.encodePortableJson(value, \"Domain Error\"); }}\nexport function decode{stem}Error(wire: string): {error_type} {{ return lensoContractRuntime.decodeDomainError<{error_type}>(wire, [{known_strings}]); }}\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 {{ return lensoContractRuntime.encodePortableJson(value, \"event\"); }}\nexport function decode{stem}Event(wire: string): {event_type} {{ return lensoContractRuntime.decodePortableJson<{event_type}>(wire); }}\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 mut request_dispatch_arms = Vec::new();
let operation_names = contract
.operations
.iter()
.map(|operation| quote_string(&operation.name))
.collect::<Vec<_>>()
.join(", ");
let stream_operation_names = contract
.operations
.iter()
.filter(|operation| operation.interaction == "stream")
.map(|operation| quote_string(&operation.name))
.collect::<Vec<_>>()
.join(", ");
let event_operation_names = contract
.operations
.iter()
.filter(|operation| operation.interaction == "event")
.map(|operation| quote_string(&operation.name))
.collect::<Vec<_>>()
.join(", ");
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 matches!(operation.interaction.as_str(), "request" | "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)),
));
}
if operation.interaction == "request" {
let provider_method = typescript_property_name(&snake_case(&operation.name));
request_dispatch_arms.push(format!(
" case {}: {{\n let request: {request_type};\n try {{\n request = decode{operation_name}Request(lensoContractRuntime.encodePortableJson(payload, \"request\"));\n }} catch (error) {{\n return {{ kind: \"runtime\", failure: {{ kind: \"protocol_violation\", detail: providerErrorMessage(error) }} }};\n }}\n try {{\n const result = await provider.{provider_method}(context, request);\n if (result.ok) {{\n return {{ kind: \"success\", value: JSON.parse(encode{operation_name}Response(result.value)) as unknown }};\n }}\n if (result.error.kind === \"domain\") {{\n return {{ kind: \"domain\", value: JSON.parse(encode{operation_name}Error(result.error.error)) as unknown }};\n }}\n return {{ kind: \"runtime\", failure: result.error.error }};\n }} catch (error) {{\n return {{ kind: \"runtime\", failure: {{ kind: \"plugin_failure\", detail: providerErrorMessage(error) }} }};\n }}\n }}",
quote_string(&operation.name),
));
}
}
let mut output = String::new();
output.push_str(TYPESCRIPT_HEADER);
output.push_str("import * as lensoContractRuntime from \"@lenso/contract-runtime\";\n\n");
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 = lensoContractRuntime.Int64;\nexport type Uint64 = lensoContractRuntime.Uint64;\nexport type Bytes = lensoContractRuntime.Bytes;\nexport type Timestamp = lensoContractRuntime.Timestamp;\nexport type Duration = lensoContractRuntime.Duration;\nexport type OptionalValue<T> = lensoContractRuntime.OptionalValue<T>;\nexport type InvocationContext = lensoContractRuntime.InvocationContext;\nexport type RuntimeFailure = lensoContractRuntime.RuntimeFailure;\nexport type UnknownDomainError = lensoContractRuntime.UnknownDomainError;\nexport type StreamEvent<Message, DomainError> = lensoContractRuntime.StreamEvent<Message, DomainError>;\nexport type StreamSession<Message, DomainError> = lensoContractRuntime.StreamSession<Message, DomainError>;\n\n");
if has_event_operations {
output.push_str("export type EventAdmission = lensoContractRuntime.EventAdmission;\nexport type EventPublishResult = lensoContractRuntime.EventPublishResult;\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');
}
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");
write!(
output,
"\nexport type ProviderDispatchOutcome =\n | {{ readonly kind: \"success\"; readonly value: unknown }}\n | {{ readonly kind: \"domain\"; readonly value: unknown }}\n | {{ readonly kind: \"runtime\"; readonly failure: RuntimeFailure }};\n\nexport interface CapabilityProviderDescriptor {{\n readonly capability_id: string;\n readonly descriptor_version: string;\n readonly operations: ReadonlyArray<string>;\n readonly stream_operations: ReadonlyArray<string>;\n readonly event_operations: ReadonlyArray<string>;\n}}\n\nexport interface CapabilityProviderBinding {{\n readonly descriptor: CapabilityProviderDescriptor;\n invokeRequest(\n operation: string,\n context: InvocationContext,\n payload: unknown,\n ): Promise<ProviderDispatchOutcome>;\n}}\n\nfunction providerErrorMessage(error: unknown): string {{\n return error instanceof Error ? error.message : String(error);\n}}\n\nexport function bind{capability_name}Provider(\n provider: {capability_name}Provider,\n): CapabilityProviderBinding {{\n return {{\n descriptor: {{\n capability_id: CAPABILITY_ID,\n descriptor_version: DESCRIPTOR_VERSION,\n operations: [{operation_names}],\n stream_operations: [{stream_operation_names}],\n event_operations: [{event_operation_names}],\n }},\n async invokeRequest(operation, context, payload) {{\n switch (operation) {{\n{}\n default:\n return {{ kind: \"runtime\", failure: {{ kind: \"unknown_operation\", operation }} }};\n }}\n }},\n }};\n}}\n\nexport type Provider = {capability_name}Provider;\nexport const bindProvider = bind{capability_name}Provider;\n",
request_dispatch_arms.join("\n")
)
.expect("writing to a String cannot fail");
output.push_str(
"\nexport const portableValueProfile = lensoContractRuntime.portableValueProfile;\n",
);
output
}