Skip to main content

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}