diff --git a/.gitignore b/.gitignore index ea8c4bf..fbe4996 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/output diff --git a/Cargo.lock b/Cargo.lock index d5484b3..fddab08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,12 +96,40 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + [[package]] name = "proc-macro2" version = "1.0.76" @@ -120,6 +148,45 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "serde" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "strsim" version = "0.10.0" @@ -142,6 +209,8 @@ name = "thanix" version = "0.1.0" dependencies = [ "clap", + "serde", + "serde_yaml", ] [[package]] @@ -150,6 +219,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unsafe-libyaml" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b" + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c8149ac..422694b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,5 @@ license = "MIT" [dependencies] clap = {version = "4.4.2", features = ["derive"]} +serde = { version = "1.0.195", features = ["derive"] } +serde_yaml = "0.9.30" diff --git a/src/bindgen.rs b/src/bindgen.rs new file mode 100644 index 0000000..b125866 --- /dev/null +++ b/src/bindgen.rs @@ -0,0 +1,210 @@ +use serde::Deserialize; +use serde_yaml::{Number, Value}; +use std::{collections::HashMap, fs::File, io::Write}; + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Schema { + #[serde(rename = "openapi")] + open_api: String, + info: SchemaInfo, + paths: HashMap, + components: ComponentSchemas, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct SchemaInfo { + title: String, + version: String, + license: HashMap, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Path { + get: Option, + post: Option, + put: Option, + patch: Option, + delete: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct PathOp { + #[serde(rename = "operationId")] + operation_id: String, + description: String, + parameters: Option>, + responses: HashMap, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Response {} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct ComponentSchemas { + schemas: HashMap, + #[serde(rename = "securitySchemes")] + security_schemes: HashMap, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Component { + #[serde(rename = "type")] + typ: String, + description: Option, + #[serde(default)] + properties: HashMap, + #[serde(default)] + required: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[allow(dead_code)] +struct Property { + #[serde(rename = "type")] + typ: Option, + #[serde(rename = "readOnly")] + read_only: Option, + format: Option, + description: Option, + #[serde(rename = "minLength")] + min_length: Option, + #[serde(rename = "maxLength")] + max_length: Option, + #[serde(rename = "enum")] + enumeration: Option>, + nullable: Option, + properties: Option, + items: Option, + #[serde(rename = "allOf")] + all_of: Option>, +} + +/// Makes a comment out of a given string. +fn make_comment(input: String, indent: usize) -> String { + return input + .split('\n') + .map(|x| format!("{}/// {}\n", "\t".repeat(indent), x)) + .collect::>() + .concat(); +} + +fn get_inner_type(items: Value) -> String { + // Get inner type of the array. + let inner_type = match items.get("$ref") { + // Struct type + Some(y) => y.as_str().unwrap().replace("#/components/schemas/", ""), + // Normal type + None => match items.get("type") { + Some(y) => match y.as_str().unwrap() { + "integer" => "i64".to_owned(), + "number" => "f64".to_owned(), + "string" => match items.get("format") { + Some(x) => match x.as_str().unwrap() { + "uri" => "Uri".to_owned(), + "date-time" => "DateTime".to_owned(), + _ => "String".to_owned(), + }, + None => "String".to_owned(), + }, + "boolean" => "bool".to_owned(), + "object" => "Json".to_owned(), + "array" => get_inner_type(match items.get("items") { + Some(z) => z.clone(), + None => panic!("array is missing items section!"), + }), + _ => panic!("unhandled type!"), + }, + // We don't know what this is so assume a JSON object. + None => "Json".to_owned(), + }, + }; + let fmt = format!("Vec<{inner_type}>"); + fmt.clone() +} + +/// Generates the Rust bindings from a file. +pub fn gen(input_path: impl AsRef) { + // Parse the schema. + let input = std::fs::read_to_string(input_path).unwrap(); + let yaml: Schema = serde_yaml::from_str(&input).unwrap(); + + // Generate output folder. + _ = std::fs::create_dir("output/"); + + // Create and open the output file for structs. + let mut types_file = File::create("output/types.rs").unwrap(); + + // For every struct. + for (name, comp) in &yaml.components.schemas { + // Keep a record of all written fields for a constructor. + let mut fields = Vec::new(); + // Prepend slashes to all lines in the documentation string. + let desc = match comp.description.clone() { + Some(d) => make_comment(d, 0), + None => String::new(), + }; + // Write description. + types_file.write_all(desc.as_bytes()).unwrap(); + // Write name. + types_file.write_all(b"pub struct ").unwrap(); + types_file.write_all(name.as_bytes()).unwrap(); + types_file.write_all(b" {\n").unwrap(); + // For every struct field. + for (prop_name, prop) in &comp.properties { + // Get the type of this field from YAML. + let yaml_type = match prop.typ.as_ref() { + Some(val) => val.as_str(), + None => continue, + }; + + let mut type_result = match yaml_type { + // "string" can mean either a plain or formatted string or an enum declaration. + "string" => match &prop.format { + Some(x) => match x.as_str() { + "uri" => "Uri".to_owned(), + "date-time" => "DateTime".to_owned(), + _ => "String".to_owned(), + }, + None => "String".to_owned(), + }, + "integer" => "i64".to_owned(), + "number" => "f64".to_owned(), + "boolean" => "bool".to_owned(), + "array" => get_inner_type(prop.items.as_ref().unwrap().clone()), + "object" => "Json".to_owned(), + _ => todo!(), + }; + + // Wrap type in an Option if nullable. + if prop.nullable.unwrap_or(false) { + type_result = format!("Option<{type_result}>"); + } + + // Escape field names if they are Rust keywords. + let name = match prop_name.as_str() { + "type" => "r#type", + _ => prop_name, + }; + + // Prepend slashes to all lines in the documentation string. + if let Some(d) = prop.description.as_ref() { + types_file + .write_all(make_comment(d.clone(), 1).as_bytes()) + .unwrap(); + }; + // Write the field to file. + types_file + .write_all(format!("\t{}: {},\n", name, type_result).as_bytes()) + .unwrap(); + fields.push((name, type_result)); + } + types_file.write_all(b"}\n\n").unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 1dd4075..4344dc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,17 @@ +mod bindgen; + use clap::Parser; /// The argument that Thanix expects to get given via the cli. #[derive(Parser, Debug)] #[command(author, version, about, long_about=None)] struct Args { - /// Path to the input yaml you want to use. + /// Path to a YAML schema file. #[arg(short, long)] - input_file: Option + input_file: Option, } fn main() { - let args: Args = Args::parse(); let ascii_art = r#" @@ -24,10 +25,14 @@ fn main() { // Welcome Message println!( - "{} \n(c) The Nezara Project. (github.com/The-Nezara/Project)\n + "{} \n(c) The Nazara Project. (github.com/The-Nazara-Project)\n Licensed under the terms of the MIT-License.\n\ Check github.com/The-Nazara-Project/Thanix/LICENSE for more info.\n", ascii_art ); + match args.input_file { + Some(file) => bindgen::gen(file), + None => println!("Error: You need to provide a YAML schema to generate from."), + } }