use crate::schema::graph::SchemaGraph;
use crate::schema::metadata::{MetadataEnvelope, TypeId};
use crate::schema::schema_type::{
BinaryRestrictions, DiscriminatorRule, NumericBound, NumericRestrictions, PathSpec,
PermissionCardSpec, QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType,
SecretSpec, TextRestrictions, UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType,
};
use serde_json::{Map, Number, Value};
use std::collections::{HashMap, HashSet};
const JSON_SCHEMA_DRAFT: &str = "https://json-schema.org/draft/2020-12/schema";
const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$";
const BASE64URL_PATTERN: &str =
"^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$";
#[derive(Clone, Copy, Debug)]
pub struct JsonSchemaConfig {
pub include_draft_marker: bool,
host_managed: HostManagedSchemaPolicy,
unsupported: UnsupportedLeafSchemaPolicy,
}
#[derive(Clone, Copy, Debug)]
enum HostManagedSchemaPolicy {
TrustedSnapshot,
Reject,
Redact,
}
#[derive(Clone, Copy, Debug)]
enum UnsupportedLeafSchemaPolicy {
Placeholder,
Reject,
}
impl JsonSchemaConfig {
pub const CANONICAL: Self = Self {
include_draft_marker: true,
host_managed: HostManagedSchemaPolicy::TrustedSnapshot,
unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
};
pub const WITHOUT_DRAFT_MARKER: Self = Self {
include_draft_marker: false,
host_managed: HostManagedSchemaPolicy::TrustedSnapshot,
unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
};
pub(crate) const EXTERNAL_INPUT: Self = Self {
include_draft_marker: false,
host_managed: HostManagedSchemaPolicy::Reject,
unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
};
pub(crate) const EXTERNAL_OUTPUT: Self = Self {
include_draft_marker: false,
host_managed: HostManagedSchemaPolicy::Redact,
unsupported: UnsupportedLeafSchemaPolicy::Placeholder,
};
pub const REFLECTION: Self = Self {
include_draft_marker: true,
host_managed: HostManagedSchemaPolicy::Reject,
unsupported: UnsupportedLeafSchemaPolicy::Reject,
};
}
pub fn to_json_schema(graph: &SchemaGraph, ty: &SchemaType) -> Value {
to_json_schema_with_config(graph, ty, JsonSchemaConfig::CANONICAL)
}
pub fn to_json_schema_with_config(
graph: &SchemaGraph,
ty: &SchemaType,
config: JsonSchemaConfig,
) -> Value {
let table = build_branch_name_table(graph, ty);
let mut root = render_type(graph, ty, true, &table, config);
let mut defs = render_defs(graph, &table, config);
add_union_branch_defs(graph, ty, &mut defs, &table, config);
if !defs.is_empty() {
if let Some(obj) = root.as_object_mut() {
obj.insert("$defs".to_string(), Value::Object(defs));
} else {
let mut wrapper = Map::new();
wrapper.insert("$defs".to_string(), Value::Object(defs));
wrapper.insert("allOf".to_string(), Value::Array(vec![root.clone()]));
root = Value::Object(wrapper);
}
}
if config.include_draft_marker
&& let Some(obj) = root.as_object_mut()
{
let mut with_schema = Map::with_capacity(obj.len() + 1);
with_schema.insert(
"$schema".to_string(),
Value::String(JSON_SCHEMA_DRAFT.to_string()),
);
for (k, v) in obj.iter() {
with_schema.insert(k.clone(), v.clone());
}
return Value::Object(with_schema);
}
root
}
pub fn to_external_input_json_schema(
graph: &SchemaGraph,
ty: &SchemaType,
include_draft_marker: bool,
) -> Value {
to_json_schema_with_config(
graph,
ty,
JsonSchemaConfig {
include_draft_marker,
..JsonSchemaConfig::EXTERNAL_INPUT
},
)
}
pub fn to_external_output_json_schema(
graph: &SchemaGraph,
ty: &SchemaType,
include_draft_marker: bool,
) -> Value {
to_json_schema_with_config(
graph,
ty,
JsonSchemaConfig {
include_draft_marker,
..JsonSchemaConfig::EXTERNAL_OUTPUT
},
)
}
pub fn to_reflection_json_schema(
graph: &SchemaGraph,
ty: &SchemaType,
include_draft_marker: bool,
) -> Value {
to_json_schema_with_config(
graph,
ty,
JsonSchemaConfig {
include_draft_marker,
..JsonSchemaConfig::REFLECTION
},
)
}
fn resolves_to_option(graph: &SchemaGraph, ty: &SchemaType) -> bool {
let mut current = ty;
let mut visited: HashSet<TypeId> = HashSet::new();
loop {
match current {
SchemaType::Option { .. } => return true,
SchemaType::Ref { id, .. } => {
if !visited.insert(id.clone()) {
return false;
}
match graph.lookup(id) {
Some(def) => current = &def.body,
None => return false,
}
}
_ => return false,
}
}
}
pub(super) fn render_defs(
graph: &SchemaGraph,
table: &BranchNameTable,
config: JsonSchemaConfig,
) -> Map<String, Value> {
let mut defs = Map::new();
for def in &graph.defs {
let mut body = render_type(graph, &def.body, false, table, config);
if let Some(name) = &def.name
&& let Some(obj) = body.as_object_mut()
{
obj.entry("title").or_insert(Value::String(name.clone()));
}
defs.insert(def.id.0.clone(), body);
}
defs
}
pub(super) fn add_union_branch_defs(
graph: &SchemaGraph,
root_ty: &SchemaType,
defs: &mut Map<String, Value>,
table: &BranchNameTable,
config: JsonSchemaConfig,
) {
let mut emitted = HashSet::new();
collect_union_branch_defs(graph, root_ty, defs, &mut emitted, table, config);
for def in &graph.defs {
collect_union_branch_defs(graph, &def.body, defs, &mut emitted, table, config);
}
}
fn collect_union_branch_defs(
graph: &SchemaGraph,
ty: &SchemaType,
defs: &mut Map<String, Value>,
emitted: &mut HashSet<String>,
table: &BranchNameTable,
config: JsonSchemaConfig,
) {
match ty {
SchemaType::Union { spec, .. } => {
for branch in spec.branches.iter() {
let key = table.name_for(branch).to_string();
if emitted.insert(key.clone()) {
let mut body = render_type(graph, &branch.body, false, table, config);
attach_metadata(&mut body, &branch.metadata);
defs.insert(
key,
obj_inline([(
"allOf",
Value::Array(vec![
body,
discriminator_constraint(&branch.discriminator),
]),
)]),
);
}
collect_union_branch_defs(graph, &branch.body, defs, emitted, table, config);
}
}
SchemaType::Record { fields, .. } => {
for f in fields {
collect_union_branch_defs(graph, &f.body, defs, emitted, table, config);
}
}
SchemaType::Variant { cases, .. } => {
for case in cases {
if let Some(p) = &case.payload {
collect_union_branch_defs(graph, p, defs, emitted, table, config);
}
}
}
SchemaType::Tuple { elements, .. } => {
for e in elements {
collect_union_branch_defs(graph, e, defs, emitted, table, config);
}
}
SchemaType::List { element, .. }
| SchemaType::FixedList { element, .. }
| SchemaType::Option { inner: element, .. } => {
collect_union_branch_defs(graph, element, defs, emitted, table, config);
}
SchemaType::Map { key, value, .. } => {
collect_union_branch_defs(graph, key, defs, emitted, table, config);
collect_union_branch_defs(graph, value, defs, emitted, table, config);
}
SchemaType::Result { spec, .. } => {
if let Some(t) = &spec.ok {
collect_union_branch_defs(graph, t, defs, emitted, table, config);
}
if let Some(t) = &spec.err {
collect_union_branch_defs(graph, t, defs, emitted, table, config);
}
}
SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
if let Some(t) = inner {
collect_union_branch_defs(graph, t, defs, emitted, table, config);
}
}
_ => {}
}
}
pub(super) struct BranchNameTable {
names: HashMap<String, String>,
}
impl BranchNameTable {
pub(super) fn name_for(&self, branch: &UnionBranch) -> &str {
let key = canonical_branch_key(branch);
self.names.get(&key).map(String::as_str).expect(
"BranchNameTable must contain every union branch reachable from the render root \
— `build_branch_name_table` is the source of truth for this invariant",
)
}
}
pub(super) fn build_branch_name_table(
graph: &SchemaGraph,
root_ty: &SchemaType,
) -> BranchNameTable {
let mut collector = BranchCollector::default();
collector.walk_type(root_ty);
for def in &graph.defs {
collector.path.clear();
let seg = def.name.clone().unwrap_or_else(|| def.id.0.clone());
collector.path.push(seg);
collector.walk_type(&def.body);
}
collector.path.clear();
let taken: HashSet<String> = graph.defs.iter().map(|d| d.id.0.clone()).collect();
collector.into_table(taken)
}
fn canonical_branch_key(branch: &UnionBranch) -> String {
let bytes = serde_json::to_vec(branch).expect("UnionBranch serializes deterministically");
let hex = blake3::hash(&bytes).to_hex();
hex.as_str()[..32].to_string()
}
#[derive(Default)]
struct BranchCollector {
keys: Vec<String>,
occurrences: HashMap<String, Occurrence>,
path: Vec<String>,
}
struct Occurrence {
tag: String,
path: Vec<String>,
}
impl BranchCollector {
fn record(&mut self, branch: &UnionBranch) {
let key = canonical_branch_key(branch);
if let std::collections::hash_map::Entry::Vacant(slot) = self.occurrences.entry(key.clone())
{
slot.insert(Occurrence {
tag: branch.tag.clone(),
path: self.path.clone(),
});
self.keys.push(key);
}
}
fn walk_type(&mut self, ty: &SchemaType) {
match ty {
SchemaType::Union { spec, .. } => {
for branch in &spec.branches {
self.record(branch);
self.path.push(branch.tag.clone());
self.walk_type(&branch.body);
self.path.pop();
}
}
SchemaType::Record { fields, .. } => {
for f in fields {
self.path.push(f.name.clone());
self.walk_type(&f.body);
self.path.pop();
}
}
SchemaType::Variant { cases, .. } => {
for case in cases {
if let Some(p) = &case.payload {
self.path.push(case.name.clone());
self.walk_type(p);
self.path.pop();
}
}
}
SchemaType::Tuple { elements, .. } => {
for (i, e) in elements.iter().enumerate() {
self.path.push(format!("item{i}"));
self.walk_type(e);
self.path.pop();
}
}
SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
self.path.push("item".to_string());
self.walk_type(element);
self.path.pop();
}
SchemaType::Option { inner, .. } => {
self.path.push("inner".to_string());
self.walk_type(inner);
self.path.pop();
}
SchemaType::Map { key, value, .. } => {
self.path.push("key".to_string());
self.walk_type(key);
self.path.pop();
self.path.push("value".to_string());
self.walk_type(value);
self.path.pop();
}
SchemaType::Result { spec, .. } => {
if let Some(t) = &spec.ok {
self.path.push("ok".to_string());
self.walk_type(t);
self.path.pop();
}
if let Some(t) = &spec.err {
self.path.push("err".to_string());
self.walk_type(t);
self.path.pop();
}
}
SchemaType::Future { inner, .. } | SchemaType::Stream { inner, .. } => {
if let Some(t) = inner {
self.path.push("inner".to_string());
self.walk_type(t);
self.path.pop();
}
}
_ => {}
}
}
fn into_table(self, mut taken: HashSet<String>) -> BranchNameTable {
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
for key in &self.keys {
let occ = &self.occurrences[key];
let preferred = sanitise_to_upper_camel(&occ.tag);
match groups.iter_mut().find(|(name, _)| name == &preferred) {
Some((_, members)) => members.push(key.clone()),
None => groups.push((preferred, vec![key.clone()])),
}
}
let mut names = HashMap::<String, String>::new();
for (preferred, members) in groups {
if members.len() == 1 && !taken.contains(&preferred) {
let only = members.into_iter().next().unwrap();
taken.insert(preferred.clone());
names.insert(only, preferred);
} else {
for key in members {
let occ = &self.occurrences[&key];
let assigned = disambiguate(&preferred, &occ.path, &taken, &key);
taken.insert(assigned.clone());
names.insert(key, assigned);
}
}
}
BranchNameTable { names }
}
}
fn disambiguate(base: &str, path: &[String], taken: &HashSet<String>, canonical: &str) -> String {
let mut candidate = base.to_string();
for seg in path.iter().rev() {
let seg_camel = sanitise_to_upper_camel(seg);
if seg_camel.is_empty() {
continue;
}
candidate = format!("{seg_camel}{candidate}");
if !taken.contains(&candidate) {
return candidate;
}
}
let suffix_len = 6.min(canonical.len());
format!("{base}_{}", &canonical[..suffix_len])
}
fn sanitise_to_upper_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper_next = true;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
if upper_next {
for u in ch.to_uppercase() {
out.push(u);
}
upper_next = false;
} else {
out.push(ch);
}
} else {
upper_next = true;
}
}
if out.is_empty() {
return "Branch".to_string();
}
if out.starts_with(|c: char| c.is_ascii_digit()) {
format!("_{out}")
} else {
out
}
}
fn discriminator_constraint(rule: &DiscriminatorRule) -> Value {
let mut obj = Map::new();
match rule {
DiscriminatorRule::Prefix { prefix } => {
obj.insert(
"pattern".to_string(),
Value::String(format!("^{}", regex_escape(prefix))),
);
}
DiscriminatorRule::Suffix { suffix } => {
obj.insert(
"pattern".to_string(),
Value::String(format!("{}$", regex_escape(suffix))),
);
}
DiscriminatorRule::Contains { substring } => {
obj.insert(
"pattern".to_string(),
Value::String(regex_escape(substring)),
);
}
DiscriminatorRule::Regex { regex } => {
obj.insert("pattern".to_string(), Value::String(regex.clone()));
}
DiscriminatorRule::FieldEquals(disc) => {
obj.insert(
"required".to_string(),
Value::Array(vec![Value::String(disc.field_name.clone())]),
);
if let Some(lit) = &disc.literal {
let props = obj
.entry("properties")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.expect("properties is object");
let field = props
.entry(disc.field_name.clone())
.or_insert_with(|| obj_inline([("type", Value::String("string".to_string()))]));
if let Some(field_obj) = field.as_object_mut() {
field_obj.insert("const".to_string(), Value::String(lit.clone()));
}
}
}
DiscriminatorRule::FieldAbsent { field_name } => {
let not = obj_inline([(
"required",
Value::Array(vec![Value::String(field_name.clone())]),
)]);
obj.insert("not".to_string(), not);
}
}
Value::Object(obj)
}
pub(super) fn render_type(
graph: &SchemaGraph,
ty: &SchemaType,
root: bool,
table: &BranchNameTable,
config: JsonSchemaConfig,
) -> Value {
let mut rendered = match ty {
SchemaType::Ref { id, .. } => obj([("$ref", Value::String(ref_pointer(id, root)))]),
SchemaType::Bool { .. } => obj([("type", Value::String("boolean".to_string()))]),
SchemaType::S8 { restrictions, .. } => {
signed_integer_schema(i8::MIN as i64, i8::MAX as i64, restrictions.as_ref())
}
SchemaType::S16 { restrictions, .. } => {
signed_integer_schema(i16::MIN as i64, i16::MAX as i64, restrictions.as_ref())
}
SchemaType::S32 { restrictions, .. } => {
signed_integer_schema(i32::MIN as i64, i32::MAX as i64, restrictions.as_ref())
}
SchemaType::S64 { restrictions, .. } => signed_64_schema(restrictions.as_ref()),
SchemaType::U8 { restrictions, .. } => {
unsigned_integer_schema(0, u8::MAX as u64, restrictions.as_ref())
}
SchemaType::U16 { restrictions, .. } => {
unsigned_integer_schema(0, u16::MAX as u64, restrictions.as_ref())
}
SchemaType::U32 { restrictions, .. } => {
unsigned_integer_schema(0, u32::MAX as u64, restrictions.as_ref())
}
SchemaType::U64 { restrictions, .. } => unsigned_64_schema(restrictions.as_ref()),
SchemaType::F32 { restrictions, .. } => {
float_schema(-(f32::MAX as f64), f32::MAX as f64, restrictions.as_ref())
}
SchemaType::F64 { restrictions, .. } => {
float_schema(-f64::MAX, f64::MAX, restrictions.as_ref())
}
SchemaType::Char { .. } => obj([
("type", Value::String("string".to_string())),
("minLength", Value::Number(1.into())),
("maxLength", Value::Number(1.into())),
]),
SchemaType::String { .. } => obj([("type", Value::String("string".to_string()))]),
SchemaType::Record { fields, .. } => {
let mut props = Map::new();
let mut required = Vec::with_capacity(fields.len());
for field in fields {
let mut field_schema = render_type(graph, &field.body, false, table, config);
attach_metadata(&mut field_schema, &field.metadata);
props.insert(field.name.clone(), field_schema);
if !resolves_to_option(graph, &field.body) {
required.push(Value::String(field.name.clone()));
}
}
obj([
("type", Value::String("object".to_string())),
("properties", Value::Object(props)),
("required", Value::Array(required)),
("additionalProperties", Value::Bool(false)),
])
}
SchemaType::Variant { cases, .. } => {
Value::Object(variant_schema(graph, cases, table, config))
}
SchemaType::Enum { cases, .. } => obj([
("type", Value::String("string".to_string())),
(
"enum",
Value::Array(cases.iter().cloned().map(Value::String).collect()),
),
]),
SchemaType::Flags { flags, .. } => obj([
("type", Value::String("array".to_string())),
(
"items",
obj([
("type", Value::String("string".to_string())),
(
"enum",
Value::Array(flags.iter().cloned().map(Value::String).collect()),
),
]),
),
("uniqueItems", Value::Bool(true)),
]),
SchemaType::Tuple { elements, .. } => {
if elements.is_empty() {
obj([
("type", Value::String("array".to_string())),
("minItems", Value::Number(0u64.into())),
("maxItems", Value::Number(0u64.into())),
])
} else {
obj([
("type", Value::String("array".to_string())),
(
"prefixItems",
Value::Array(
elements
.iter()
.map(|e| render_type(graph, e, false, table, config))
.collect(),
),
),
("items", Value::Bool(false)),
("minItems", Value::Number((elements.len() as u64).into())),
])
}
}
SchemaType::List { element, .. } => obj([
("type", Value::String("array".to_string())),
("items", render_type(graph, element, false, table, config)),
]),
SchemaType::FixedList {
element, length, ..
} => obj([
("type", Value::String("array".to_string())),
("items", render_type(graph, element, false, table, config)),
("minItems", Value::Number((*length).into())),
("maxItems", Value::Number((*length).into())),
]),
SchemaType::Map { key, value, .. } => {
let pair = obj([
("type", Value::String("array".to_string())),
(
"prefixItems",
Value::Array(vec![
render_type(graph, key, false, table, config),
render_type(graph, value, false, table, config),
]),
),
("items", Value::Bool(false)),
("minItems", Value::Number(2.into())),
("maxItems", Value::Number(2.into())),
]);
obj([
("type", Value::String("array".to_string())),
("items", pair),
])
}
SchemaType::Option { inner, .. } => obj([(
"oneOf",
Value::Array(vec![
obj([("type", Value::String("null".to_string()))]),
render_type(graph, inner, false, table, config),
]),
)]),
SchemaType::Result { spec, .. } => Value::Object(result_schema(graph, spec, table, config)),
SchemaType::Text { restrictions, .. } => Value::Object(text_schema(restrictions)),
SchemaType::Binary { restrictions, .. } => Value::Object(binary_schema(restrictions)),
SchemaType::Path { spec, .. } => Value::Object(path_schema(spec)),
SchemaType::Url { restrictions, .. } => Value::Object(url_schema(restrictions)),
SchemaType::Uuid { .. } => obj([
("type", Value::String("string".to_string())),
("format", Value::String("uuid".to_string())),
]),
SchemaType::Datetime { .. } => obj([
("type", Value::String("string".to_string())),
("format", Value::String("date-time".to_string())),
]),
SchemaType::Duration { .. } => obj([
("type", Value::String("object".to_string())),
("properties", obj([("nanoseconds", signed_64_schema(None))])),
(
"required",
Value::Array(vec![Value::String("nanoseconds".to_string())]),
),
("additionalProperties", Value::Bool(false)),
(
"title",
Value::String("Duration in nanoseconds".to_string()),
),
]),
SchemaType::Quantity { spec, .. } => Value::Object(quantity_schema(spec)),
SchemaType::Union { spec, .. } => Value::Object(union_schema(graph, spec, table, config)),
SchemaType::Secret { spec, .. } => {
host_managed_schema(config.host_managed, "secret", || {
Value::Object(secret_schema(spec))
})
}
SchemaType::QuotaToken { spec, .. } => {
host_managed_schema(config.host_managed, "quota-token", || {
Value::Object(quota_token_schema(spec))
})
}
SchemaType::PermissionCard { spec, .. } => {
host_managed_schema(config.host_managed, "permission-card", || {
Value::Object(permission_card_schema(spec))
})
}
SchemaType::Future { .. } | SchemaType::Stream { .. } => match config.unsupported {
UnsupportedLeafSchemaPolicy::Placeholder => obj([
("type", Value::String("null".to_string())),
(
"description",
Value::String("WASI P3 placeholder".to_string()),
),
]),
UnsupportedLeafSchemaPolicy::Reject => obj([("not", Value::Object(Map::new()))]),
},
};
attach_metadata(&mut rendered, ty.metadata());
rendered
}
fn host_managed_schema(
policy: HostManagedSchemaPolicy,
kind: &str,
trusted: impl FnOnce() -> Value,
) -> Value {
match policy {
HostManagedSchemaPolicy::TrustedSnapshot => trusted(),
HostManagedSchemaPolicy::Reject => obj([
("not", Value::Object(Map::new())),
(
"description",
Value::String(format!(
"Host-managed {kind} capabilities cannot be supplied externally"
)),
),
]),
HostManagedSchemaPolicy::Redact => obj([
("type", Value::String("string".to_string())),
("const", Value::String(format!("<redacted: {kind}>"))),
(
"description",
Value::String(format!(
"Host-managed {kind} capability values are redacted"
)),
),
]),
}
}
fn ref_pointer(id: &TypeId, _root: bool) -> String {
ref_to_def_key(&id.0)
}
pub(super) fn ref_to_def_key(key: &str) -> String {
format!("#/$defs/{}", escape_pointer_token(key))
}
fn signed_integer_schema(min: i64, max: i64, restrictions: Option<&NumericRestrictions>) -> Value {
let minimum = restrictions
.and_then(|value| value.min)
.and_then(|bound| match bound {
NumericBound::Signed(value) => Some(value),
_ => None,
})
.unwrap_or(min)
.max(min);
let maximum = restrictions
.and_then(|value| value.max)
.and_then(|bound| match bound {
NumericBound::Signed(value) => Some(value),
_ => None,
})
.unwrap_or(max)
.min(max);
obj([
("type", Value::String("integer".to_string())),
("minimum", Value::Number(Number::from(minimum))),
("maximum", Value::Number(Number::from(maximum))),
])
}
fn unsigned_integer_schema(
min: u64,
max: u64,
restrictions: Option<&NumericRestrictions>,
) -> Value {
let minimum = restrictions
.and_then(|value| value.min)
.and_then(|bound| match bound {
NumericBound::Unsigned(value) => Some(value),
_ => None,
})
.unwrap_or(min)
.max(min);
let maximum = restrictions
.and_then(|value| value.max)
.and_then(|bound| match bound {
NumericBound::Unsigned(value) => Some(value),
_ => None,
})
.unwrap_or(max)
.min(max);
obj([
("type", Value::String("integer".to_string())),
("minimum", Value::Number(Number::from(minimum))),
("maximum", Value::Number(Number::from(maximum))),
])
}
fn float_schema(min: f64, max: f64, restrictions: Option<&NumericRestrictions>) -> Value {
let mut schema = Map::new();
schema.insert("type".to_string(), Value::String("number".to_string()));
if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.min)
&& let Some(value) = Number::from_f64(f64::from_bits(bits).max(min))
{
schema.insert("minimum".to_string(), Value::Number(value));
}
if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.max)
&& let Some(value) = Number::from_f64(f64::from_bits(bits).min(max))
{
schema.insert("maximum".to_string(), Value::Number(value));
}
Value::Object(schema)
}
fn unsigned_64_schema(restrictions: Option<&NumericRestrictions>) -> Value {
let minimum = restrictions
.and_then(|value| value.min)
.and_then(|bound| match bound {
NumericBound::Unsigned(value) => Some(value),
_ => None,
})
.unwrap_or(0);
let maximum = restrictions
.and_then(|value| value.max)
.and_then(|bound| match bound {
NumericBound::Unsigned(value) => Some(value),
_ => None,
})
.unwrap_or(u64::MAX);
obj([
("type", Value::String("string".to_string())),
("format", Value::String("uint64".to_string())),
("pattern", Value::String("^(?:0|[1-9][0-9]*)$".to_string())),
("x-golem-minimum", Value::String(minimum.to_string())),
("x-golem-maximum", Value::String(maximum.to_string())),
])
}
fn signed_64_schema(restrictions: Option<&NumericRestrictions>) -> Value {
let minimum = restrictions
.and_then(|value| value.min)
.and_then(|bound| match bound {
NumericBound::Signed(value) => Some(value),
_ => None,
})
.unwrap_or(i64::MIN);
let maximum = restrictions
.and_then(|value| value.max)
.and_then(|bound| match bound {
NumericBound::Signed(value) => Some(value),
_ => None,
})
.unwrap_or(i64::MAX);
obj([
("type", Value::String("string".to_string())),
("format", Value::String("int64".to_string())),
(
"pattern",
Value::String("^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".to_string()),
),
("x-golem-minimum", Value::String(minimum.to_string())),
("x-golem-maximum", Value::String(maximum.to_string())),
])
}
fn variant_schema(
graph: &SchemaGraph,
cases: &[VariantCaseType],
table: &BranchNameTable,
config: JsonSchemaConfig,
) -> Map<String, Value> {
let one_of: Vec<Value> = cases
.iter()
.map(|case| match &case.payload {
None => obj([("const", Value::String(case.name.clone()))]),
Some(payload_ty) => {
let mut props = Map::new();
props.insert(
case.name.clone(),
render_type(graph, payload_ty, false, table, config),
);
obj([
("type", Value::String("object".to_string())),
("properties", Value::Object(props)),
(
"required",
Value::Array(vec![Value::String(case.name.clone())]),
),
("additionalProperties", Value::Bool(false)),
])
}
})
.collect();
let mut out = Map::new();
out.insert("oneOf".to_string(), Value::Array(one_of));
out
}
fn result_schema(
graph: &SchemaGraph,
spec: &ResultSpec,
table: &BranchNameTable,
config: JsonSchemaConfig,
) -> Map<String, Value> {
let ok_inner = spec
.ok
.as_deref()
.map(|t| render_type(graph, t, false, table, config))
.unwrap_or_else(|| obj([("type", Value::String("null".to_string()))]));
let err_inner = spec
.err
.as_deref()
.map(|t| render_type(graph, t, false, table, config))
.unwrap_or_else(|| obj([("type", Value::String("null".to_string()))]));
let one_of = vec![
obj([
("type", Value::String("object".to_string())),
(
"properties",
Value::Object({
let mut m = Map::new();
m.insert("ok".to_string(), ok_inner);
m
}),
),
("required", Value::Array(vec![Value::String("ok".into())])),
("additionalProperties", Value::Bool(false)),
]),
obj([
("type", Value::String("object".to_string())),
(
"properties",
Value::Object({
let mut m = Map::new();
m.insert("err".to_string(), err_inner);
m
}),
),
("required", Value::Array(vec![Value::String("err".into())])),
("additionalProperties", Value::Bool(false)),
]),
];
let mut out = Map::new();
out.insert("oneOf".to_string(), Value::Array(one_of));
out
}
fn text_schema(restrictions: &TextRestrictions) -> Map<String, Value> {
let mut text_field = Map::new();
text_field.insert("type".to_string(), Value::String("string".to_string()));
if let Some(min) = restrictions.min_length {
text_field.insert("minLength".to_string(), Value::Number(min.into()));
}
if let Some(max) = restrictions.max_length {
text_field.insert("maxLength".to_string(), Value::Number(max.into()));
}
if let Some(regex) = &restrictions.regex {
text_field.insert("pattern".to_string(), Value::String(regex.clone()));
}
let mut properties = Map::new();
properties.insert("text".to_string(), Value::Object(text_field));
let mut language_field = Map::new();
language_field.insert("type".to_string(), Value::String("string".to_string()));
if let Some(langs) = &restrictions.languages {
language_field.insert(
"enum".to_string(),
Value::Array(langs.iter().cloned().map(Value::String).collect()),
);
}
properties.insert("language".to_string(), Value::Object(language_field));
let mut m = Map::new();
m.insert("type".to_string(), Value::String("object".to_string()));
m.insert("properties".to_string(), Value::Object(properties));
m.insert(
"required".to_string(),
Value::Array(vec![Value::String("text".to_string())]),
);
m.insert("additionalProperties".to_string(), Value::Bool(false));
m
}
fn binary_schema(restrictions: &BinaryRestrictions) -> Map<String, Value> {
let mut bytes_field = Map::new();
bytes_field.insert("type".to_string(), Value::String("string".to_string()));
bytes_field.insert(
"contentEncoding".to_string(),
Value::String("base64url".to_string()),
);
bytes_field.insert(
"pattern".to_string(),
Value::String(BASE64URL_PATTERN.to_string()),
);
if let Some(min) = restrictions.min_bytes {
bytes_field.insert(
"minLength".to_string(),
Value::Number(base64url_no_pad_len(min).into()),
);
}
if let Some(max) = restrictions.max_bytes {
bytes_field.insert(
"maxLength".to_string(),
Value::Number(base64url_no_pad_len(max).into()),
);
}
let mut mime_field = Map::new();
mime_field.insert("type".to_string(), Value::String("string".to_string()));
mime_field.insert(
"pattern".to_string(),
Value::String(MIME_TYPE_PATTERN.to_string()),
);
if let Some(mimes) = &restrictions.mime_types {
mime_field.insert(
"enum".to_string(),
Value::Array(mimes.iter().cloned().map(Value::String).collect()),
);
}
let mut properties = Map::new();
properties.insert("bytes".to_string(), Value::Object(bytes_field));
properties.insert("mimeType".to_string(), Value::Object(mime_field));
let mut m = Map::new();
m.insert("type".to_string(), Value::String("object".to_string()));
m.insert("properties".to_string(), Value::Object(properties));
m.insert(
"required".to_string(),
Value::Array(vec![Value::String("bytes".to_string())]),
);
m.insert("additionalProperties".to_string(), Value::Bool(false));
m
}
fn path_schema(spec: &PathSpec) -> Map<String, Value> {
let mut m = Map::new();
m.insert("type".to_string(), Value::String("string".to_string()));
m.insert("format".to_string(), Value::String("file-path".to_string()));
let kind = match spec.kind {
crate::schema::schema_type::PathKind::File => "file",
crate::schema::schema_type::PathKind::Directory => "directory",
crate::schema::schema_type::PathKind::Any => "any",
};
let direction = match spec.direction {
crate::schema::schema_type::PathDirection::Input => "input",
crate::schema::schema_type::PathDirection::Output => "output",
crate::schema::schema_type::PathDirection::InOut => "inout",
};
m.insert(
"title".to_string(),
Value::String(format!("{direction} {kind} path")),
);
let mut description = Vec::new();
if let Some(exts) = &spec.allowed_extensions {
description.push(format!("Allowed extensions: {}", exts.join(", ")));
}
if let Some(mimes) = &spec.allowed_mime_types {
description.push(format!("Allowed MIME types: {}", mimes.join(", ")));
}
if !description.is_empty() {
m.insert(
"description".to_string(),
Value::String(description.join("; ")),
);
}
m
}
fn url_schema(restrictions: &UrlRestrictions) -> Map<String, Value> {
let mut m = Map::new();
m.insert("type".to_string(), Value::String("string".to_string()));
m.insert("format".to_string(), Value::String("uri".to_string()));
m.insert("title".to_string(), Value::String("URL".to_string()));
let mut description = Vec::new();
if let Some(schemes) = &restrictions.allowed_schemes {
description.push(format!("Allowed schemes: {}", schemes.join(", ")));
}
if let Some(hosts) = &restrictions.allowed_hosts {
description.push(format!("Allowed hosts: {}", hosts.join(", ")));
}
if !description.is_empty() {
m.insert(
"description".to_string(),
Value::String(description.join("; ")),
);
}
m
}
fn quantity_schema(spec: &QuantitySpec) -> Map<String, Value> {
let mut props = Map::new();
props.insert("mantissa".to_string(), signed_64_schema(None));
props.insert(
"scale".to_string(),
obj([("type", Value::String("integer".to_string()))]),
);
props.insert(
"unit".to_string(),
obj([("type", Value::String("string".to_string()))]),
);
let mut m = Map::new();
m.insert("type".to_string(), Value::String("object".to_string()));
m.insert("properties".to_string(), Value::Object(props));
m.insert(
"required".to_string(),
Value::Array(vec![
Value::String("mantissa".to_string()),
Value::String("scale".to_string()),
Value::String("unit".to_string()),
]),
);
m.insert("additionalProperties".to_string(), Value::Bool(false));
m.insert(
"title".to_string(),
Value::String(format!("Quantity ({})", spec.base_unit)),
);
let mut description = Vec::new();
if let Some(min) = &spec.min {
description.push(format!("min: {}", render_quantity(min)));
}
if let Some(max) = &spec.max {
description.push(format!("max: {}", render_quantity(max)));
}
if !description.is_empty() {
m.insert(
"description".to_string(),
Value::String(description.join("; ")),
);
}
m
}
fn render_quantity(q: &QuantityValue) -> String {
format!("{}e-{} {}", q.mantissa, q.scale, q.unit)
}
fn secret_schema(_spec: &SecretSpec) -> Map<String, Value> {
let secret_id = obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("uuid".to_string())),
]);
let config_key = obj_inline([
("type", Value::String("array".to_string())),
(
"items",
obj_inline([("type", Value::String("string".to_string()))]),
),
]);
let version = obj_inline([
("type", Value::String("integer".to_string())),
("minimum", Value::Number(0.into())),
]);
let resolved_at = obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("date-time".to_string())),
]);
let category = obj_inline([("type", Value::String("string".to_string()))]);
let mut properties = Map::new();
properties.insert("secretId".to_string(), secret_id);
properties.insert("configKey".to_string(), config_key);
properties.insert("version".to_string(), version);
properties.insert("resolvedAt".to_string(), resolved_at);
properties.insert("category".to_string(), category);
let mut m = Map::new();
m.insert("type".to_string(), Value::String("object".to_string()));
m.insert("properties".to_string(), Value::Object(properties));
m.insert(
"required".to_string(),
Value::Array(vec![
Value::String("secretId".to_string()),
Value::String("version".to_string()),
Value::String("resolvedAt".to_string()),
]),
);
m.insert("additionalProperties".to_string(), Value::Bool(false));
m
}
fn quota_token_schema(_spec: &QuotaTokenSpec) -> Map<String, Value> {
let env_id = obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("uuid".to_string())),
]);
let resource_name = obj_inline([("type", Value::String("string".to_string()))]);
let expected_use = obj_inline([(
"oneOf",
Value::Array(vec![
obj_inline([
("type", Value::String("string".to_string())),
("pattern", Value::String("^[0-9]+$".to_string())),
]),
obj_inline([
("type", Value::String("integer".to_string())),
("minimum", Value::Number(0u64.into())),
]),
]),
)]);
let last_credit = obj_inline([(
"oneOf",
Value::Array(vec![
obj_inline([
("type", Value::String("string".to_string())),
("pattern", Value::String("^-?[0-9]+$".to_string())),
]),
obj_inline([("type", Value::String("integer".to_string()))]),
]),
)]);
let last_credit_at = obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("date-time".to_string())),
]);
let mut properties = Map::new();
properties.insert("environmentId".to_string(), env_id);
properties.insert("resourceName".to_string(), resource_name);
properties.insert("expectedUse".to_string(), expected_use);
properties.insert("lastCredit".to_string(), last_credit);
properties.insert("lastCreditAt".to_string(), last_credit_at);
let mut m = Map::new();
m.insert("type".to_string(), Value::String("object".to_string()));
m.insert("properties".to_string(), Value::Object(properties));
m.insert(
"required".to_string(),
Value::Array(vec![
Value::String("environmentId".to_string()),
Value::String("resourceName".to_string()),
Value::String("expectedUse".to_string()),
Value::String("lastCredit".to_string()),
Value::String("lastCreditAt".to_string()),
]),
);
m.insert("additionalProperties".to_string(), Value::Bool(false));
m
}
fn permission_card_schema(_spec: &PermissionCardSpec) -> Map<String, Value> {
let card_id = obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("uuid".to_string())),
]);
let parent_ids = obj_inline([
("type", Value::String("array".to_string())),
(
"items",
obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("uuid".to_string())),
]),
),
]);
let expires_at = obj_inline([
("type", Value::String("string".to_string())),
("format", Value::String("date-time".to_string())),
]);
let polymorphic = obj_inline([("type", Value::String("boolean".to_string()))]);
let mut properties = Map::new();
properties.insert("cardId".to_string(), card_id);
properties.insert("parentIds".to_string(), parent_ids);
properties.insert("expiresAt".to_string(), expires_at);
properties.insert("polymorphic".to_string(), polymorphic);
let mut m = Map::new();
m.insert("type".to_string(), Value::String("object".to_string()));
m.insert("properties".to_string(), Value::Object(properties));
m.insert(
"required".to_string(),
Value::Array(vec![
Value::String("cardId".to_string()),
Value::String("polymorphic".to_string()),
]),
);
m.insert("additionalProperties".to_string(), Value::Bool(false));
m
}
fn union_schema(
graph: &SchemaGraph,
spec: &UnionSpec,
table: &BranchNameTable,
config: JsonSchemaConfig,
) -> Map<String, Value> {
let one_of: Vec<Value> = spec
.branches
.iter()
.map(|b| obj([("$ref", Value::String(ref_to_def_key(table.name_for(b))))]))
.collect();
let mut m = Map::new();
m.insert("oneOf".to_string(), Value::Array(one_of));
if let Some(disc_field) = openapi_discriminator(spec) {
let mut mapping = Map::new();
for branch in spec.branches.iter() {
let literal = match &branch.discriminator {
DiscriminatorRule::FieldEquals(disc) => disc.literal.clone(),
_ => None,
};
if let Some(lit) = literal {
mapping.insert(lit, Value::String(ref_to_def_key(table.name_for(branch))));
}
}
let mut d = Map::new();
d.insert("propertyName".to_string(), Value::String(disc_field));
if !mapping.is_empty() {
d.insert("mapping".to_string(), Value::Object(mapping));
}
m.insert("discriminator".to_string(), Value::Object(d));
}
let _ = graph;
let _ = config;
m
}
fn openapi_discriminator(spec: &UnionSpec) -> Option<String> {
let mut field: Option<String> = None;
for branch in spec.branches.iter() {
match &branch.discriminator {
DiscriminatorRule::FieldEquals(disc) => {
let _ = disc.literal.as_ref()?;
match &field {
Some(prev) if prev != &disc.field_name => return None,
Some(_) => {}
None => field = Some(disc.field_name.clone()),
}
}
_ => return None,
}
}
field
}
fn attach_metadata(target: &mut Value, metadata: &MetadataEnvelope) {
if metadata.is_empty() {
return;
}
let Some(obj) = target.as_object_mut() else {
return;
};
if let Some(doc) = &metadata.doc {
obj.entry("description")
.or_insert(Value::String(doc.clone()));
}
if !metadata.examples.is_empty() {
obj.entry("examples").or_insert_with(|| {
Value::Array(
metadata
.examples
.iter()
.map(|e| Value::String(e.clone()))
.collect(),
)
});
}
if let Some(dep) = &metadata.deprecated {
obj.entry("deprecated").or_insert(Value::Bool(true));
obj.entry("x-golem-deprecation-note")
.or_insert(Value::String(dep.clone()));
}
}
fn base64url_no_pad_len(n: u32) -> u64 {
let n = n as u64;
4 * (n / 3)
+ match n % 3 {
0 => 0,
1 => 2,
2 => 3,
_ => unreachable!(),
}
}
fn escape_pointer_token(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'~' => out.push_str("~0"),
'/' => out.push_str("~1"),
other => out.push(other),
}
}
out
}
fn regex_escape(s: &str) -> String {
let specials: &[char] = &[
'\\', '^', '$', '.', '|', '?', '*', '+', '(', ')', '[', ']', '{', '}',
];
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if specials.contains(&ch) {
out.push('\\');
}
out.push(ch);
}
out
}
fn obj<I: IntoIterator<Item = (&'static str, Value)>>(entries: I) -> Value {
let mut map = Map::new();
for (k, v) in entries {
map.insert(k.to_string(), v);
}
Value::Object(map)
}
fn obj_inline<I: IntoIterator<Item = (&'static str, Value)>>(entries: I) -> Value {
obj(entries)
}