Created
November 11, 2023 19:17
-
-
Save bweis/353adf147803f2320efd144c7a96a990 to your computer and use it in GitHub Desktop.
Generating ts types from rust
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[package] | |
name = "typegen" | |
version = "0.1.0" | |
edition = "2021" | |
[dependencies] | |
clap = { version = "4.4.7", features = ["derive"] } | |
# -- App Libs | |
lib-base = { path = "../../libs/lib-base" } | |
lib-core = { path = "../../libs/lib-core" } | |
schemars = { version = "0.8", features = ["uuid1"] } | |
# -- External Libs | |
serde_json = "1.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as fs from "node:fs/promises"; | |
import * as path from "path"; | |
import * as url from "url"; | |
import { compile } from "json-schema-to-typescript"; | |
const dirname = path.dirname(url.fileURLToPath(import.meta.url)); | |
async function main() { | |
let schemasPath = path.join(dirname, "jsonschemas"); | |
let schemaFiles = (await fs.readdir(schemasPath)).filter((x) => | |
x.endsWith(".json"), | |
); | |
// Compile all types, stripping out duplicates. This is a bit dumb but the easiest way to | |
// do it since we can't suppress generation of definition references. | |
let compiledTypes = new Set(); | |
for (let filename of schemaFiles) { | |
let filePath = path.join(schemasPath, filename); | |
let schema = JSON.parse(await fs.readFile(filePath)); | |
let compiled = await compile(schema, schema.title, { | |
bannerComment: "", | |
additionalProperties: false, | |
}); | |
let typeDefinitions = compiled.split("export"); | |
typeDefinitions = hackToDeduplicateTypes(typeDefinitions); | |
for (let type of typeDefinitions) { | |
if (!type) { | |
continue; | |
} | |
compiledTypes.add("export " + type.trim()); | |
} | |
} | |
console.log({ compiledTypes }); | |
let output = Array.from(compiledTypes).join("\n\n"); | |
let outputPath = path.join(dirname, "types.ts"); | |
try { | |
let existing = await fs.readFile(outputPath); | |
if (existing == output) { | |
// Skip writing if it hasn't changed, so that we don't confuse any sort of incremental builds. | |
// This check isn't ideal but the script runs quickly enough and rarely enough that it doesn't matter. | |
console.log("Schemas are up to date"); | |
return; | |
} | |
} catch (e) { | |
// It's fine if there's no output from a previous run. | |
if (e.code !== "ENOENT") { | |
throw e; | |
} | |
} | |
await fs.writeFile(outputPath, output); | |
console.log(`Wrote Typescript types to ${outputPath}`); | |
} | |
/* | |
* This is a hack to deduplicate types. It's not ideal but it's the easiest way to do it since we can't trust | |
* the schemars library with cyclic types. | |
*/ | |
function hackToDeduplicateTypes(typeDefinitions) { | |
const re = new RegExp("(?<prefix>[a-zA-Z]+)(?<suffix>[0-9]*)"); | |
const deDuplications = Object.entries( | |
typeDefinitions | |
// split on spaces | |
.map((t) => t.split(" ").filter((t) => t !== "")) | |
// add index and split type | |
.map((t, index) => { | |
const { groups } = re.exec(t[1]); | |
return { | |
type: t[1], | |
index, | |
...groups, | |
}; | |
}) | |
// Group by prefix. | |
// When NodeJS supports Object.groupBy, we can use that instead. | |
.reduce( | |
(acc, value, _, __, k = value.prefix) => ( | |
(acc[k] || (acc[k] = [])).push(value), acc | |
), | |
{}, | |
), | |
) | |
// Filter out undefined values and singletons (no replacement needed) | |
.filter(([key, value]) => !key || value.length > 1) | |
// Map to the find/replace/indicesToDelete format | |
.map(([key, value]) => { | |
const find = value.filter((v) => v.type !== key); | |
const indicesToDelete = find.map((f) => f.index); | |
indicesToDelete.sort((a, b) => b - a); // sorted back to front so we don't mess up the indices | |
return { | |
find: find.map((f) => f.type), | |
replace: key, | |
indicesToDelete, | |
}; | |
}); | |
// Get all the indices to delete and sort them in decreasing order to remove indices from eachType | |
deDuplications | |
.flatMap((d) => d.indicesToDelete) | |
.sort((a, b) => a - b) | |
.forEach((i) => typeDefinitions.splice(i, 1)); | |
// Replace each instance of deduplication.find with deduplication.replace for each typeDefinition | |
const replaced = typeDefinitions | |
.filter((td) => td !== "") | |
.map((typeDefinition) => { | |
let replacedTypeDefinition = typeDefinition; | |
deDuplications.forEach((deDuplication) => { | |
if (deDuplication.find.length === 0) { | |
return; | |
} | |
const regex = new RegExp(`\\b${deDuplication.find.join("|")}\\b`, "gm"); | |
replacedTypeDefinition = typeDefinition.replaceAll( | |
regex, | |
deDuplication.replace, | |
); | |
}); | |
console.log({ before: typeDefinition, after: replacedTypeDefinition }); | |
return replacedTypeDefinition; | |
}); | |
return replaced; | |
} | |
main().catch((e) => { | |
console.error(e); | |
process.exit(1); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use std::env; | |
use clap::Parser; | |
// use lib_core::model::user::{TestStruct, User, UserForCreate, UserForLogin}; | |
use schemars::{schema::RootSchema, schema_for, JsonSchema}; | |
fn write_schema( | |
dir: &std::path::Path, | |
name: &str, | |
schema: &RootSchema, | |
) -> std::io::Result<()> { | |
let output = serde_json::to_string_pretty(schema).unwrap(); | |
let output_path = dir.join(format!("{}.json", name)); | |
std::fs::write(output_path, output) | |
} | |
// TESTING | |
use std::{collections::BTreeSet, rc::Rc}; | |
#[derive(JsonSchema)] | |
enum Role { | |
Foo, | |
Admin, | |
} | |
#[derive(JsonSchema)] | |
enum Gender { | |
Male, | |
Female, | |
Other, | |
} | |
#[derive(JsonSchema)] | |
struct User { | |
user_id: i32, | |
first_name: String, | |
last_name: String, | |
role: Role, | |
family: Vec<User>, | |
gender: Gender, | |
} | |
#[derive(JsonSchema)] | |
enum Vehicle { | |
Bicycle { color: String }, | |
Car { brand: String, color: String }, | |
} | |
#[derive(JsonSchema)] | |
struct Point<T> { | |
time: u64, | |
value: T, | |
} | |
#[derive(JsonSchema)] | |
struct Series { | |
points: Vec<Point<u64>>, | |
} | |
#[derive(JsonSchema)] | |
enum SimpleEnum { | |
A, | |
B, | |
} | |
#[derive(JsonSchema)] | |
enum ComplexEnum { | |
A, | |
B { foo: String, bar: f64 }, | |
W(SimpleEnum), | |
F { nested: SimpleEnum }, | |
V(Vec<Series>), | |
U(Box<User>), | |
} | |
#[derive(JsonSchema)] | |
enum InlineComplexEnum { | |
A, | |
B { foo: String, bar: f64 }, | |
W(SimpleEnum), | |
F { nested: SimpleEnum }, | |
V(Vec<Series>), | |
U(Box<User>), | |
} | |
#[derive(JsonSchema)] | |
struct ComplexStruct { | |
pub string_tree: Option<Rc<BTreeSet<String>>>, | |
} | |
#[derive(Parser, Debug)] | |
#[command(author, version, about, long_about = None)] | |
struct Args { | |
#[arg(short, long)] | |
output_path: Option<std::path::PathBuf>, | |
} | |
fn main() -> std::io::Result<()> { | |
let args = Args::parse(); | |
let output_dir = args.output_path.unwrap_or( | |
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas"), | |
); | |
let e = std::fs::DirBuilder::new().create(&output_dir); | |
if let Err(e) = e { | |
if e.kind() != std::io::ErrorKind::AlreadyExists { | |
return Err(e.into()); | |
} | |
} | |
// let schema = schema_for!(User); | |
// write_schema(&output_dir, "User", &schema)?; | |
// let schema = schema_for!(UserForCreate); | |
// write_schema(&output_dir, "UserForCreate", &schema)?; | |
// let schema = schema_for!(UserForLogin); | |
// write_schema(&output_dir, "UserForLogin", &schema)?; | |
// let schema = schema_for!(TestStruct); | |
// write_schema(&output_dir, "TestStruct", &schema)?; | |
let schema = schema_for!(Role); | |
write_schema(&output_dir, "Role", &schema)?; | |
let schema = schema_for!(Gender); | |
write_schema(&output_dir, "Gender", &schema)?; | |
let schema = schema_for!(User); | |
write_schema(&output_dir, "User", &schema)?; | |
let schema = schema_for!(Vehicle); | |
write_schema(&output_dir, "Vehicle", &schema)?; | |
let schema = schema_for!(Point<u64>); | |
write_schema(&output_dir, "Point", &schema)?; | |
let schema = schema_for!(SimpleEnum); | |
write_schema(&output_dir, "SimpleEnum", &schema)?; | |
let schema = schema_for!(ComplexEnum); | |
write_schema(&output_dir, "ComplexEnum", &schema)?; | |
let schema = schema_for!(InlineComplexEnum); | |
write_schema(&output_dir, "InlineComplexEnum", &schema)?; | |
let schema = schema_for!(ComplexStruct); | |
write_schema(&output_dir, "ComplexStruct", &schema)?; | |
Ok(()) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "typegen", | |
"version": "1.0.0", | |
"description": "", | |
"main": "index.js", | |
"type": "module", | |
"scripts": { | |
"generate": "cargo run --manifest-path ../../api/Cargo.toml -p typegen -- --output-path ./jsonschemas && node generate-ts-types.js", | |
"generate:clean": "rm -r ./jsonschemas; npm run generate", | |
"test": "echo \"Error: no test specified\" && exit 1" | |
}, | |
"author": "", | |
"license": "ISC", | |
"dependencies": { | |
"json-schema-to-typescript": "^13.1.1" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment