cratefield_client_ts/lib.rs
1//! The TypeScript client generator (issue #155). See the crate README for
2//! the pipeline this sits in; the short version: a `/__surface` document
3//! in, the files of a typed `@cratefield/client` package out, with no I/O
4//! anywhere in between — the caller writes what [`generate`] returns.
5
6#![doc = include_str!("../README.md")]
7#![forbid(unsafe_code)]
8
9use std::fmt::Write as _;
10
11use sha2::{Digest, Sha256};
12
13/// The input side: the `/__surface` document read into the normalized
14/// Tables model — [`contract::extract`], [`contract::Contract`],
15/// [`contract::TableModel`], [`contract::ColumnModel`]. Public so a
16/// pipeline (and the tests) can inspect the reading without running the
17/// emitter behind it.
18pub mod contract;
19mod emitter;
20
21/// A generated file: a package-relative path and its full contents.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct GeneratedFile {
24 pub path: String,
25 pub contents: String,
26}
27
28/// The generated client package.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct GeneratedPackage {
31 /// The venture the client speaks for, from the document.
32 pub venture: String,
33 /// The npm package name the files implement: `@cratefield/client`.
34 pub package_name: String,
35 /// A sha256 over the generated files, for a reproducible-publish
36 /// check: the same document always generates the same bytes, so the
37 /// same hash.
38 pub composition_hash: String,
39 /// Every file to write, in a stable order. Paths are relative to the
40 /// package root (`src/index.ts`, not `./src/index.ts`).
41 pub files: Vec<GeneratedFile>,
42}
43
44/// Why generation refused. Every case is a contract this generator cannot
45/// honour, and every `Display` says what to do about it.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum GenerateError {
48 /// The document's `surface_api` is not the contract this generator
49 /// speaks: a newer (or corrupted) server than the generator.
50 UnsupportedSurfaceApi {
51 /// What the document declared.
52 found: u32,
53 /// What this generator reads.
54 expected: u32,
55 },
56 /// The document declares no `tables` module, so there is no contract
57 /// to generate a client from.
58 NoTablesModule {
59 /// The modules the document does declare.
60 modules: Vec<String>,
61 },
62 /// An action in the Tables module whose name is not one of the five
63 /// verbs the module publishes (`list-`/`read-`/`create-`/
64 /// `replace-`/`delete-`, then the table name).
65 UnknownAction {
66 /// The action's name.
67 action: String,
68 },
69 /// An action whose name parses but whose method or path is not what
70 /// its verb promises — a contract drifted from its own vocabulary.
71 UnreadableAction {
72 /// The action's name.
73 action: String,
74 /// What is wrong with it.
75 why: String,
76 },
77 /// A table whose name is not a safe identifier: lowercase snake-case
78 /// (`[a-z][a-z0-9_]*`, no double or trailing underscore), the same
79 /// rule `cratefield_tables::is_identifier` enforces on a declaration.
80 /// The `/__surface` document is untrusted input — the documented usage
81 /// is `curl -s https://venture/__surface | fz client-ts` — and the
82 /// name is interpolated into the generated TypeScript, so anything it
83 /// cannot spell is refused rather than renamed: a silently renamed
84 /// table would produce a client that calls the wrong route.
85 UnsupportedTableName {
86 /// The offending table's name, as the document spelled it.
87 table: String,
88 },
89}
90
91impl std::fmt::Display for GenerateError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 GenerateError::UnsupportedSurfaceApi { found, expected } => write!(
95 f,
96 "the surface document speaks contract {found}, and this generator reads \
97 contract {expected}; generate the client with a generator built for it"
98 ),
99 GenerateError::NoTablesModule { modules } if modules.is_empty() => write!(
100 f,
101 "the surface document declares no modules at all; a venture that serves \
102 declared tables publishes them as the `tables` module — compose the \
103 tables module into the venture and regenerate"
104 ),
105 GenerateError::NoTablesModule { modules } => write!(
106 f,
107 "the surface document declares no `tables` module (it names [{}]); a \
108 client can only be generated from the tables the venture declares — \
109 compose the tables module into the venture and regenerate",
110 modules.join(", ")
111 ),
112 GenerateError::UnknownAction { action } => write!(
113 f,
114 "the tables module publishes the action `{action}`, which is not one of \
115 the five verbs the generator reads (`list-`, `read-`, `create-`, \
116 `replace-`, `delete-`, each followed by the table name); regenerate \
117 with a generator that speaks this contract"
118 ),
119 GenerateError::UnreadableAction { action, why } => write!(
120 f,
121 "the tables module publishes the action `{action}`, and {why}; the \
122 contract has drifted from its own vocabulary — regenerate with a \
123 generator that speaks it",
124 ),
125 GenerateError::UnsupportedTableName { table } => write!(
126 f,
127 "the tables module publishes the table `{table}`, whose name is not a \
128 safe identifier — a table name must be lowercase `[a-z][a-z0-9_]*`, no \
129 double or trailing underscore — and it is interpolated into the \
130 generated TypeScript, so this generator refuses anything it cannot \
131 spell rather than renaming it; rename the table in the venture's \
132 declaration and regenerate",
133 ),
134 }
135 }
136}
137
138impl std::error::Error for GenerateError {}
139
140/// Generate the files of a typed `@cratefield/client` package from a
141/// `/__surface` document.
142///
143/// Pure: the same document always generates byte-identical files, and
144/// nothing here touches a disk — the caller writes
145/// [`GeneratedPackage::files`] wherever the pipeline wants them.
146///
147/// # Errors
148///
149/// [`GenerateError::UnsupportedSurfaceApi`] when the document speaks a
150/// contract version this generator does not read,
151/// [`GenerateError::NoTablesModule`] when the document has no `tables`
152/// module to generate from, [`GenerateError::UnknownAction`] /
153/// [`GenerateError::UnreadableAction`] when the Tables module publishes
154/// an action outside the verb vocabulary the generator reads, and
155/// [`GenerateError::UnsupportedTableName`] when a table's name is not a
156/// safe identifier the generated TypeScript can carry.
157pub fn generate(
158 document: &cratefield_core::SurfaceDocument,
159) -> Result<GeneratedPackage, GenerateError> {
160 let parsed = contract::extract(document)?;
161 let files = emitter::emit(&parsed);
162 let composition_hash = composition_hash_of(&files);
163 Ok(GeneratedPackage {
164 venture: parsed.venture,
165 package_name: emitter::PACKAGE_NAME.to_owned(),
166 composition_hash,
167 files,
168 })
169}
170
171/// The sha256 composition hash over a file set, in write order: each
172/// file's path, a zero byte, its contents, a zero byte. Exported so a
173/// publish pipeline can recompute it from the files it wrote and compare
174/// it against what the generation recorded — the same construction
175/// `cratefield_manifest::generate` uses for the venture crate.
176#[must_use]
177pub fn composition_hash_of(files: &[GeneratedFile]) -> String {
178 let mut hasher = Sha256::new();
179 for file in files {
180 hasher.update(file.path.as_bytes());
181 hasher.update([0]);
182 hasher.update(file.contents.as_bytes());
183 hasher.update([0]);
184 }
185 let digest = hasher.finalize();
186 digest.iter().fold(String::new(), |mut acc, byte| {
187 let _ = write!(acc, "{byte:02x}");
188 acc
189 })
190}