Skip to main content

lenso_contract_codegen/
browser.rs

1use std::{fmt::Write as _, path::Path};
2
3use super::{
4    CodegenError, contract_ir, load_descriptor, pascal_case, quote_string, snake_case,
5    typescript_property_name,
6};
7
8const BROWSER_RUNTIME: &str = r#"const isRecord = (value) => typeof value === "object" && value !== null;
9function validatePortableJson(value) {
10  if (typeof value === "number") {
11    if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) throw new Error("wire JSON contains an unsafe number");
12    return;
13  }
14  if (Array.isArray(value)) { for (const item of value) validatePortableJson(item); return; }
15  if (isRecord(value)) { for (const item of Object.values(value)) validatePortableJson(item); }
16}
17function decimalInRange(value, signed) {
18  if (!(signed ? /^-?(0|[1-9][0-9]*)$/ : /^(0|[1-9][0-9]*)$/).test(value)) return false;
19  try {
20    const number = BigInt(value);
21    return signed
22      ? number >= -9223372036854775808n && number <= 9223372036854775807n
23      : number >= 0n && number <= 18446744073709551615n;
24  } catch { return false; }
25}
26function isCanonicalBase64(value) {
27  if (value === "") return true;
28  if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return false;
29  try { return btoa(atob(value)) === value; } catch { return false; }
30}
31function isRfc3339(value) {
32  const match = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/.exec(value);
33  if (!match) return false;
34  const [, year, month, day, hour, minute, second, , offsetHour = "0", offsetMinute = "0"] = match;
35  const numericYear = Number(year), numericMonth = Number(month);
36  const leap = numericYear % 4 === 0 && (numericYear % 100 !== 0 || numericYear % 400 === 0);
37  const days = [0, 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][numericMonth] ?? 0;
38  return numericMonth >= 1 && numericMonth <= 12 && Number(day) >= 1 && Number(day) <= days
39    && Number(hour) <= 23 && Number(minute) <= 59 && Number(second) <= 60
40    && Number(offsetHour) <= 23 && Number(offsetMinute) <= 59;
41}
42function isIso8601Duration(value) {
43  let index = value.startsWith("-") ? 1 : 0;
44  if (value[index] !== "P") return false;
45  index += 1;
46  let inTime = false, sawComponent = false, sawTime = false;
47  while (index < value.length) {
48    if (value[index] === "T") { if (inTime || index + 1 === value.length) return false; inTime = true; index += 1; continue; }
49    const start = index;
50    let separatorSeen = false;
51    while (index < value.length && (/[0-9]/.test(value[index]) || (!separatorSeen && value[index] === "."))) {
52      separatorSeen ||= value[index] === ".";
53      index += 1;
54    }
55    if (index === start || index >= value.length) return false;
56    const unit = value[index];
57    if (!(inTime ? ["H", "M", "S"] : ["Y", "M", "W", "D"]).includes(unit)) return false;
58    if (inTime) sawTime = true;
59    sawComponent = true;
60    index += 1;
61  }
62  return sawComponent && (!inTime || sawTime);
63}
64function matchesFormat(value, format) {
65  if (!format) return true;
66  if (format === "int64") return decimalInRange(value, true);
67  if (format === "uint64") return decimalInRange(value, false);
68  if (format === "byte") return isCanonicalBase64(value);
69  if (format === "date-time") return isRfc3339(value);
70  if (format === "duration") return isIso8601Duration(value);
71  return false;
72}
73function matchesSchema(value, schema) {
74  if (Array.isArray(schema.oneOf) && schema.oneOf.filter((candidate) => matchesSchema(value, candidate)).length !== 1) return false;
75  if (Array.isArray(schema.anyOf) && !schema.anyOf.some((candidate) => matchesSchema(value, candidate))) return false;
76  if (Object.hasOwn(schema, "const") && value !== schema.const) return false;
77  if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false;
78  if (Array.isArray(schema.type)) return schema.type.some((type) => matchesSchema(value, { ...schema, type }));
79  if (schema.type === "null") return value === null;
80  if (schema.type === "string") {
81    if (typeof value !== "string" || !matchesFormat(value, schema.format)) return false;
82    const length = Array.from(value).length;
83    if (schema.minLength !== undefined && length < schema.minLength) return false;
84    if (schema.maxLength !== undefined && length > schema.maxLength) return false;
85  }
86  if (schema.type === "boolean" && typeof value !== "boolean") return false;
87  if (schema.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) return false;
88  if (schema.type === "integer" && !Number.isSafeInteger(value)) return false;
89  if (["number", "integer"].includes(schema.type)) {
90    if (schema.minimum !== undefined && value < schema.minimum) return false;
91    if (schema.maximum !== undefined && value > schema.maximum) return false;
92    if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) return false;
93    if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) return false;
94  }
95  if (schema.type === "array") {
96    if (!Array.isArray(value)) return false;
97    if (schema.minItems !== undefined && value.length < schema.minItems) return false;
98    if (schema.maxItems !== undefined && value.length > schema.maxItems) return false;
99    return !schema.items || value.every((item) => matchesSchema(item, schema.items));
100  }
101  if (schema.type === "object") {
102    if (!isRecord(value) || Array.isArray(value)) return false;
103    if ((schema.required ?? []).some((key) => !Object.hasOwn(value, key))) return false;
104    const properties = schema.properties ?? {};
105    for (const [key, item] of Object.entries(value)) {
106      if (Object.hasOwn(properties, key)) { if (!matchesSchema(item, properties[key])) return false; }
107      else if (schema.additionalProperties === false) return false;
108      else if (isRecord(schema.additionalProperties) && !matchesSchema(item, schema.additionalProperties)) return false;
109    }
110  }
111  return true;
112}
113function validateSchema(value, schema, label) {
114  validatePortableJson(value);
115  if (!matchesSchema(value, schema)) throw new Error(`invalid ${label} payload`);
116}
117function validateDomainError(value, schema) {
118  if (matchesSchema(value, schema)) return;
119  if (typeof value === "string") return;
120  if (isRecord(value) && typeof value.code === "string") return;
121  throw new Error("invalid Domain Error payload");
122}
123function validateResult(result, responseSchema, errorSchema) {
124  if (!isRecord(result) || typeof result.ok !== "boolean") throw new Error("invalid Capability result envelope");
125  if (result.ok) {
126    if (!Object.hasOwn(result, "value")) throw new Error("successful Capability result is missing value");
127    validateSchema(result.value, responseSchema, "response");
128    return;
129  }
130  if (!isRecord(result.error) || !["domain", "runtime"].includes(result.error.kind)) throw new Error("invalid invocation error envelope");
131  if (result.error.kind === "domain") validateDomainError(result.error.error, errorSchema);
132  else if (!isRecord(result.error.error) || typeof result.error.error.kind !== "string") throw new Error("invalid Runtime Failure payload");
133}
134"#;
135
136/// Generates an executable browser request client from the same validated
137/// Descriptor IR used by the checked-in Rust and TypeScript bindings.
138pub fn generate_browser_request_client(path: &Path) -> Result<String, CodegenError> {
139    let descriptor = load_descriptor(path)?;
140    let contract = contract_ir(&descriptor);
141    let client_name = contract
142        .capability_id
143        .split('@')
144        .next()
145        .and_then(|id| id.rsplit('.').next())
146        .map_or_else(|| "Capability".to_owned(), pascal_case);
147    let mut methods = String::new();
148    for operation in contract
149        .operations
150        .iter()
151        .filter(|operation| operation.interaction == "request")
152    {
153        let descriptor_operation = descriptor
154            .operations
155            .iter()
156            .find(|candidate| candidate.name == operation.name)
157            .expect("Contract IR operations come from the Descriptor");
158        let method = typescript_property_name(&snake_case(&operation.name));
159        let route = format!(
160            "/api/capabilities/{}/{}",
161            contract.capability_id, operation.name
162        );
163        writeln!(
164            methods,
165            "    async {method}(request, token) {{\n      validateSchema(request, {request_schema}, \"request\");\n      const response = await fetchTransport({}, {{\n        method: \"POST\",\n        headers: {{ \"authorization\": `Bearer ${{token}}`, \"content-type\": \"application/json\" }},\n        body: JSON.stringify(request),\n      }});\n      const result = await response.json();\n      validatePortableJson(result);\n      validateResult(result, {response_schema}, {error_schema});\n      return result;\n    }},",
166            quote_string(&route),
167            request_schema = descriptor_operation.request_schema,
168            response_schema = descriptor_operation.response_schema,
169            error_schema = descriptor_operation.domain_error_schema,
170        )
171        .expect("writing to a String cannot fail");
172    }
173    let mut output = format!(
174        "// @generated by lenso-contract-codegen from {}; do not edit.\n{BROWSER_RUNTIME}",
175        contract.capability_id
176    );
177    write!(
178        output,
179        "export function create{client_name}Client(fetchTransport = fetch) {{\n  return {{\n{methods}  }};\n}}\n"
180    )
181    .expect("writing to a String cannot fail");
182    Ok(output)
183}