From a2e54207541de34d15bad2991e6425d8497747f3 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 25 Feb 2024 22:00:40 +0100 Subject: [PATCH 01/10] Rewrite struct and path generation --- .gitignore | 5 + CONTRIBUTING.md | 2 +- Cargo.lock | 31 ++ Cargo.toml | 2 + src/bindgen.rs | 480 +++++++------------------------ src/main.rs | 24 +- src/pathgen.rs | 150 ++++++++++ src/structgen.rs | 42 +++ src/templates/README.md.template | 3 + src/templates/ascii_art.template | 6 + src/templates/path.template | 9 +- src/templates/usings.template | 6 +- 12 files changed, 365 insertions(+), 395 deletions(-) create mode 100644 src/pathgen.rs create mode 100644 src/structgen.rs create mode 100644 src/templates/README.md.template create mode 100644 src/templates/ascii_art.template diff --git a/.gitignore b/.gitignore index 05e8f13..faedb8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ /target /output /thanix_client + +# IDE files +.vs/ +.vscode/ +.idea/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72c7539..deb114d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ This will ensure that you have all the necessary tools and libraries to build an 4. **Install and set up pre-commit for code quality checks**. This tool will automatically execute the `hooks` we implemented which will check your code for formatting or styling issue before each commit. -Note: If `pre-commit` fails on execution, be sure to run `cargo format` and `cargo clippy` on your code and fix any issues +Note: If `pre-commit` fails on execution, be sure to run `cargo fmt` and `cargo clippy` on your code and fix any issues raised by these tools. # Making changes diff --git a/Cargo.lock b/Cargo.lock index 7710d55..bbd98da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "check_keyword" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d532198271d4fb82f944bd25527e760a6cfc966c7d592525e092d4c6a4f787" + [[package]] name = "clap" version = "4.4.16" @@ -131,6 +137,7 @@ checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -139,6 +146,17 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +[[package]] +name = "openapiv3" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc02deea53ffe807708244e5914f6b099ad7015a207ee24317c22112e17d9c5c" +dependencies = [ + "indexmap", + "serde", + "serde_json", +] + [[package]] name = "proc-macro2" version = "1.0.76" @@ -183,6 +201,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.30" @@ -217,8 +246,10 @@ dependencies = [ name = "thanix" version = "0.1.0-alpha.9" dependencies = [ + "check_keyword", "clap", "convert_case", + "openapiv3", "serde", "serde_yaml", ] diff --git a/Cargo.toml b/Cargo.toml index d1845fb..fe1757a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,9 @@ license = "GPL-3.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +check_keyword = "0.2.0" clap = {version = "4.4.2", features = ["derive"]} convert_case = "0.6.0" +openapiv3 = "2.0.0" serde = { version = "1.0.195", features = ["derive"] } serde_yaml = "0.9.30" diff --git a/src/bindgen.rs b/src/bindgen.rs index 2bb9e93..d70f763 100644 --- a/src/bindgen.rs +++ b/src/bindgen.rs @@ -1,106 +1,57 @@ -use convert_case::{Case, Casing}; -use serde::Deserialize; -use serde_yaml::{Number, Value}; +use crate::pathgen; +use crate::structgen; +use openapiv3::Schema; +use openapiv3::SchemaKind; +use openapiv3::Type; +use openapiv3::{OpenAPI, ReferenceOr}; use std::{ - collections::HashMap, fs::{self, File}, - io::Write, + io::{self, Write}, + path::Path, }; -#[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: Option, - description: Option, - #[serde(default)] - tags: Vec, - #[serde(default)] - parameters: Vec, - responses: Option>, -} - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct Parameter { - #[serde(rename = "in")] - input: String, - name: String, - schema: Option, -} +/// Generate Rust bindings from an OpenAPI schema. +pub fn gen(input_path: impl AsRef, output_path: impl AsRef) { + // Parse the schema. + let input = fs::read_to_string(input_path).unwrap(); + let api: OpenAPI = serde_yaml::from_str(&input).unwrap(); -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct Response {} + // Populate the output directory. + let output_path = output_path.as_ref(); + create_lib_dir(output_path).unwrap(); -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct ComponentSchemas { - schemas: HashMap, - #[serde(rename = "securitySchemes")] - security_schemes: HashMap, -} + // Create and open the output file for structs. + let mut types_file = File::create(output_path.join("src/").join("types.rs")).unwrap(); + // TODO: We don't really need these. + //write!(types_file, "{}", include_str!("templates/usings.template")).unwrap(); + + // For every component. + for (name, schema) in &api.components.unwrap().schemas { + let s = match schema { + ReferenceOr::Item(x) => x, + _ => continue, + }; + // Generate struct and write it to file. + if let Some(structure) = structgen::gen(name, s) { + types_file.write_all(structure.as_bytes()).unwrap(); + } + } -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -struct Component { - #[serde(rename = "type")] - typ: String, - description: Option, - #[serde(default)] - properties: HashMap, - #[serde(default)] - required: Vec, -} + // Create and open the output file for paths. + let mut paths_file = File::create(output_path.join("src/").join("paths.rs")).unwrap(); + write!(paths_file, "{}", include_str!("templates/usings.template")).unwrap(); -#[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>, + // For every path. + for (name, path) in &api.paths.paths { + let p = match path { + ReferenceOr::Item(x) => x, + _ => continue, + }; + // Generate paths and write to file. + if let Some(paths) = pathgen::gen(name, p) { + paths_file.write_all(paths.as_bytes()).unwrap(); + } + } } /// Create all necessary structures and directories for the crate. @@ -108,301 +59,84 @@ struct Property { /// /// # Arguments /// -/// - `output_name: &str` - The name of the output library given by the CLI. Default `output/`. -/// -/// # Panics -/// -/// This function panics when the `output/` directory does not exist. -fn create_lib_dir(output_name: &str) -> Result<(), Box> { +/// - `output_name: &Path` - The name of the output library given by the CLI. Default `output`. +fn create_lib_dir(output_path: &Path) -> io::Result<()> { println!("Starting repackaging into crate..."); - let source_files = ["paths.rs", "types.rs", "util.rs"]; - if fs::metadata(output_name).is_err() { - panic!("Fatal: Output directory does not exist!"); - } - - std::env::set_current_dir(output_name)?; + // Create the output folder. + _ = fs::create_dir(output_path); - for src_file in &source_files { - if fs::metadata(src_file).is_err() { - panic!("Source file {} does not exist!", src_file); - } - } + // Create the "src" subdirectory. + let src_dir = output_path.join("src/"); + fs::create_dir_all(&src_dir)?; - let src_dir = "src"; - fs::create_dir_all(src_dir)?; - - for src_file in source_files { - let dest_path = format!("{}/{}", src_dir, src_file); - fs::rename(src_file, &dest_path)?; - } - - let mut lib_file = fs::File::create(format!("{}/lib.rs", src_dir))?; - write!(lib_file, "{}", include_str!("templates/lib.rs.template"))?; - - let cargo_toml = format!(include_str!("templates/Cargo.toml.template"), output_name); - - fs::write("Cargo.toml", cargo_toml)?; - - let readme_contents = r#" -# Readme - -This output was automatically generated by `Thanix` (github.com/The-Nazara-Project/Thanix). -"#; + // Create the "src/util.rs" file. + fs::write( + &src_dir.join("util.rs"), + include_str!("templates/util.rs.template"), + )?; - fs::write("README.md", readme_contents)?; + // Create the "src/lib.rs" file. + fs::write( + &src_dir.join("lib.rs"), + include_str!("templates/lib.rs.template"), + )?; + + // Create the "Cargo.toml" file. + let mut cargo_file = fs::File::create(output_path.join("Cargo.toml"))?; + write!( + cargo_file, + include_str!("templates/Cargo.toml.template"), + // In case the user provides a relative path, use the last directory + // as the crate name. + output_path.file_name().unwrap().to_string_lossy() + )?; + + // Create the "README.md" file. + fs::write("README.md", include_str!("templates/README.md.template"))?; println!("Output successfully repackaged!"); Ok(()) } /// 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 make_fn_name_from_path(input: &str) -> String { - input.replace("/api/", "").replace('/', "_") -} - -/// Replaces reserved keywords in an input string for use in Rust. -fn fix_keywords(input: &str) -> String { - input - .replace("type", "typ") - .replace("struct", "structure") - .replace("fn", "func") -} - -fn pathop_to_string(path: &str, input: &PathOp, method: &str) -> String { - // Create a new struct for the query parameters. - let fn_struct_params = input - .parameters - // Filter out only the query inputs. - .iter() - .filter(|x| x.input == "query") - .enumerate() - .map(|(s, p)| { - format!( - "\t{}: Option<{}>{}\n", - fix_keywords(&p.name), - get_inner_type(p.schema.as_ref().unwrap().clone(), false), - if s < &input.parameters.len() - 1 { - "," - } else { - "" - } - ) - }) - .collect::(); - let fn_name = input - .operation_id - .clone() - .unwrap_or(make_fn_name_from_path(&path)); - let fn_struct_name = fn_name.to_case(Case::Pascal) + "Query"; - let fn_struct = format!("#[derive(Debug, Serialize, Deserialize, Default)]\npub struct {fn_struct_name} {{\n{fn_struct_params}}}"); - let comment = make_comment(input.description.clone().unwrap(), 0); - let mut path_args = input - .parameters - // Filter out only the path inputs. - .iter() - .filter(|x| x.input == "path") - .enumerate() - .map(|(s, p)| { - format!( - "{}: {}{}", - fix_keywords(&p.name), - get_inner_type(p.schema.as_ref().unwrap().clone(), false), - if s < &input.parameters.len() - 1 { - "," - } else { - "" - } - ) - }) - .collect::(); - if !path_args.is_empty() { - path_args = ", ".to_owned() + &path_args; +pub fn make_comment(input: Option, indent: usize) -> String { + match input { + Some(x) => x + .split('\n') + .map(|x| format!("{}/// {}\n", "\t".repeat(indent), x)) + .collect::>() + .concat(), + None => String::new(), } - return format!( - include_str!("templates/path.template"), - fn_struct, comment, fn_name, fn_struct_name, path_args, method, path - ); } -fn get_inner_type(items: Value, append_vec: bool) -> 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" => match items.get("format") { - Some(x) => match x.as_str().unwrap() { - "int8" => "i8".to_owned(), - "int16" => "i16".to_owned(), - "int32" => "i32".to_owned(), - _ => "i64".to_owned(), - }, - None => "i64".to_owned(), +pub fn type_to_string(ty: &ReferenceOr) -> String { + match ty { + // If the type is a reference, just extract the component name. + ReferenceOr::Reference { reference } => reference.replace("#/components/schemas/", ""), + ReferenceOr::Item(item) => { + let mut base = match &item.schema_kind { + SchemaKind::Type(t) => match t { + Type::String(_) => "String".to_owned(), + Type::Number(_) => "f64".to_owned(), + Type::Integer(_) => "i64".to_owned(), + // JSON object, but Rust has no easy way to support this, so just ask for a string. + Type::Object(_) => "String".to_owned(), + Type::Boolean(_) => "bool".to_owned(), + Type::Array(x) => { + let items = x.items.as_ref().unwrap().clone().unbox(); + format!("Vec<{}>", type_to_string(&items)) + } }, - "number" => "f64".to_owned(), - "string" => match items.get("format") { - Some(x) => match x.as_str().unwrap() { - "uri" => "Url".to_owned(), - _ => "String".to_owned(), - }, - None => "String".to_owned(), - }, - "boolean" => "bool".to_owned(), - "object" => "String".to_owned(), - "array" => get_inner_type( - match items.get("items") { - Some(z) => z.clone(), - None => panic!("array is missing items section!"), - }, - true, - ), - _ => panic!("unhandled type!"), - }, - // We don't know what this is so assume a JSON object. - None => "String".to_owned(), - }, - }; - if append_vec { - let fmt = format!("Vec<{inner_type}>"); - return fmt.clone(); - } - inner_type -} - -/// Executes a closure if the Option contains a Some value. -fn if_some(this: Option, func: F) { - if let Some(ref x) = this { - func(x); - } -} - -/// 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("thanix_client"); - - // Create and open the output file for structs. - let mut types_file = File::create("thanix_client/types.rs").unwrap(); - types_file - .write_all(include_str!("templates/usings.template").as_bytes()) - .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"#[derive(Debug, Serialize, Deserialize, Default)]\npub 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" => "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(), true), - "object" => "serde_json::value::Value".to_owned(), - _ => todo!(), + // Very likely a JSON object. + _ => "String".to_owned(), }; - - // Wrap type in an Option if nullable. - if prop.nullable.unwrap_or(false) { - type_result = format!("Option<{type_result}>"); + // If property is nullable, we treat it as an optional argument. + if item.schema_data.nullable { + base = format!("Option<{}>", base); } - - // 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 pub {}: {},\n", name, type_result).as_bytes()) - .unwrap(); - fields.push((name, type_result)); + base } - types_file.write_all(b"}\n\n").unwrap(); - } - - // Create and open the output file for paths. - let mut paths_file = File::create("thanix_client/paths.rs").unwrap(); - - paths_file - .write_all(include_str!("templates/usings.template").as_bytes()) - .unwrap(); - - // For every path. - for (name, path) in &yaml.paths { - if_some(path.get.as_ref(), |op| { - paths_file - .write_all(pathop_to_string(name, op, "get").as_bytes()) - .unwrap() - }); - if_some(path.put.as_ref(), |op| { - paths_file - .write_all(pathop_to_string(name, op, "put").as_bytes()) - .unwrap() - }); - if_some(path.post.as_ref(), |op| { - paths_file - .write_all(pathop_to_string(name, op, "post").as_bytes()) - .unwrap() - }); - if_some(path.patch.as_ref(), |op| { - paths_file - .write_all(pathop_to_string(name, op, "patch").as_bytes()) - .unwrap() - }); - if_some(path.delete.as_ref(), |op| { - paths_file - .write_all(pathop_to_string(name, op, "delete").as_bytes()) - .unwrap() - }); } - fs::write( - "thanix_client/util.rs", - include_str!("templates/util.rs.template").as_bytes(), - ) - .unwrap(); - create_lib_dir("thanix_client").unwrap(); } diff --git a/src/main.rs b/src/main.rs index d7f40b1..9863075 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,8 @@ mod bindgen; +mod pathgen; +mod structgen; + +use std::path::PathBuf; use clap::Parser; @@ -6,33 +10,25 @@ use clap::Parser; #[derive(Parser, Debug)] #[command(author, version, about, long_about=None)] struct Args { + #[arg(short, long, default_value = "output")] + output: PathBuf, /// Path to a YAML schema file. - #[arg(short, long)] - input_file: Option, + input: Option, } fn main() { let args: Args = Args::parse(); - let ascii_art = r#" - ████████╗██╗ ██╗ █████╗ ███╗ ██╗██╗██╗ ██╗ - ╚══██╔══╝██║ ██║██╔══██╗████╗ ██║██║╚██╗██╔╝ - ██║ ███████║███████║██╔██╗ ██║██║ ╚███╔╝ - ██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██╔██╗ - ██║ ██║ ██║██║ ██║██║ ╚████║██║██╔╝ ██╗ - ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝ - "#; - // Welcome Message println!( "{} \n(c) The Nazara Project. (github.com/The-Nazara-Project)\n Licensed under the terms of the GPL-v3.0-License.\n\ Check github.com/The-Nazara-Project/Thanix/LICENSE for more info.\n", - ascii_art + include_str!("templates/ascii_art.template") ); - match args.input_file { - Some(file) => bindgen::gen(file), + match args.input { + Some(file) => bindgen::gen(file, args.output), None => println!("Error: You need to provide a YAML schema to generate from."), } } diff --git a/src/pathgen.rs b/src/pathgen.rs new file mode 100644 index 0000000..361fad4 --- /dev/null +++ b/src/pathgen.rs @@ -0,0 +1,150 @@ +use crate::bindgen::{self, make_comment}; +use check_keyword::CheckKeyword; +use convert_case::{Case, Casing}; +use openapiv3::{Operation, Parameter, ParameterSchemaOrContent, PathItem, ReferenceOr}; + +pub fn gen(name: &str, path_item: &PathItem) -> Option { + let mut result = String::new(); + + if let Some(op) = &path_item.get { + result += gen_fn(name, "get", op).as_str(); + } + if let Some(op) = &path_item.put { + result += gen_fn(name, "put", op).as_str(); + } + if let Some(op) = &path_item.post { + result += gen_fn(name, "post", op).as_str(); + } + if let Some(op) = &path_item.delete { + result += gen_fn(name, "delete", op).as_str(); + } + if let Some(op) = &path_item.options { + result += gen_fn(name, "options", op).as_str(); + } + if let Some(op) = &path_item.head { + result += gen_fn(name, "head", op).as_str(); + } + if let Some(op) = &path_item.patch { + result += gen_fn(name, "patch", op).as_str(); + } + if let Some(op) = &path_item.trace { + result += gen_fn(name, "trace", op).as_str(); + } + + Some(result) +} + +fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { + // Build description. + let mut description = op.description.clone().unwrap_or(String::new()); + description = bindgen::make_comment( + Some(format!("`{}`\n{}", op_type.to_uppercase(), description)), + 0, + ); + + // Build function name. + let fn_name = op + .operation_id + .clone() + .unwrap_or(make_fn_name_from_path(name) + "_" + op_type); + + let mut fn_struct_params = Vec::new(); + let mut fn_header_params = Vec::new(); + let mut fn_path_params = Vec::new(); + + // Assign all parameters to their respective slots. + for param in op.parameters.clone() { + // Filter out only parameter items. + let p = match param { + ReferenceOr::Item(x) => x, + _ => continue, + }; + match p { + // If we have a query, append as a field to the query struct. + Parameter::Query { parameter_data, .. } => { + // We only respect Schemas. + let query_param_type = match ¶meter_data.format { + ParameterSchemaOrContent::Schema(schema) => schema, + _ => continue, + }; + // Format as a struct field. + fn_struct_params.push(format!( + "{}\t{}: Option<{}>,\n", + make_comment(parameter_data.description, 1), + parameter_data.name.into_safe(), + bindgen::type_to_string(query_param_type) + )) + } + // If we have a header, append to the header params. + Parameter::Header { parameter_data, .. } => { + // We only respect Schemas. + let header_param_type = match ¶meter_data.format { + ParameterSchemaOrContent::Schema(schema) => schema, + _ => continue, + }; + + fn_header_params.push(( + parameter_data.name.clone(), + bindgen::type_to_string(header_param_type), + )); + } + // If we have a path, append to the path params. + Parameter::Path { parameter_data, .. } => { + // We only respect Schemas. + let path_param_type = match ¶meter_data.format { + ParameterSchemaOrContent::Schema(schema) => schema, + _ => continue, + }; + + fn_path_params.push(format!( + ", {}: {}", + parameter_data.name.into_safe(), + bindgen::type_to_string(path_param_type) + )); + } + // TODO + Parameter::Cookie { .. } => { + todo!() + } + } + } + + // Build the query struct for this function. + let fn_struct_name = fn_name.to_case(Case::Pascal) + "Query"; + let fn_struct = format!( + "#[derive(Serialize, Deserialize, Debug)]\npub struct {} {{\n{}\n}}\n", + fn_struct_name, + fn_struct_params.into_iter().collect::() + ); + + // Build the header args. + let fn_header_args = &fn_header_params + .iter() + .map(|(name, ty)| format!(", header_{}: {}", name, ty)) + .collect::(); + + // Build the header calls. + let fn_header = &fn_header_params + .iter() + .map(|(name, _)| format!("\n.header(\"{}\", header_{})", &name, &name)) + .collect::(); + + // Build the function body. + let fn_body = format!( + include_str!("templates/path.template"), + op_type, name, fn_header + ); + + // Build the function args. + let fn_path_args = fn_path_params.into_iter().collect::(); + + // TODO + format!( + "{}{}pub fn {}(state: &ThanixClient, query: {}{}{}) -> Result {{\n{}\n}}\n", + fn_struct, description, fn_name, fn_struct_name, fn_header_args, fn_path_args, fn_body + ) +} + +fn make_fn_name_from_path(input: &str) -> String { + input.replace("/api/", "").replace('/', "_") +} diff --git a/src/structgen.rs b/src/structgen.rs new file mode 100644 index 0000000..3b8e4fa --- /dev/null +++ b/src/structgen.rs @@ -0,0 +1,42 @@ +use crate::bindgen; +use check_keyword::CheckKeyword; +use openapiv3::{ReferenceOr, Schema, SchemaKind, Type}; + +pub fn gen(name: &str, schema: &Schema) -> Option { + let typ = match &schema.schema_kind { + SchemaKind::Type(x) => x, + _ => return None, + }; + // If not an ObjectType, return None. + let obj = match &typ { + Type::Object(x) => x, + _ => return None, + }; + + // Assemble struct string. + let mut result = "struct ".to_owned(); + result += name; + result += " {\n"; + + // For every component property. + for (prop_name, prop) in &obj.properties { + let p = prop.clone().unbox(); + // Assemble a field declaration in the struct. + let type_name = bindgen::type_to_string(&p); + + // If the property has a description, prepend a doc string. + if let ReferenceOr::Item(item) = &p { + if let Some(desc) = &item.schema_data.description { + result += bindgen::make_comment(Some(desc.clone()), 1).as_str(); + } + } + result += "\t"; + result += &prop_name.clone().into_safe(); + result += ": "; + result += &type_name; + result += ",\n"; + } + result += "}\n"; + + Some(result) +} diff --git a/src/templates/README.md.template b/src/templates/README.md.template new file mode 100644 index 0000000..5fc3c24 --- /dev/null +++ b/src/templates/README.md.template @@ -0,0 +1,3 @@ +# Readme + +This output was automatically generated by `Thanix` (github.com/The-Nazara-Project/Thanix). \ No newline at end of file diff --git a/src/templates/ascii_art.template b/src/templates/ascii_art.template new file mode 100644 index 0000000..38478c8 --- /dev/null +++ b/src/templates/ascii_art.template @@ -0,0 +1,6 @@ +████████╗██╗ ██╗ █████╗ ███╗ ██╗██╗██╗ ██╗ +╚══██╔══╝██║ ██║██╔══██╗████╗ ██║██║╚██╗██╔╝ + ██║ ███████║███████║██╔██╗ ██║██║ ╚███╔╝ + ██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██╔██╗ + ██║ ██║ ██║██║ ██║██║ ╚████║██║██╔╝ ██╗ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝ \ No newline at end of file diff --git a/src/templates/path.template b/src/templates/path.template index 7dbbafb..42348dd 100644 --- a/src/templates/path.template +++ b/src/templates/path.template @@ -1,5 +1,4 @@ -{} -{} -pub fn {}(state: &ThanixClient, query: {}{}) -> Result {{ - return state.client.{}(format!("{{}}{}?{{}}", state.base_url, serde_qs::to_string(&query).unwrap())).header("Authorization", format!("Token {{}}", state.authentication_token)).send(); -}} + return state.client + .{}(format!("{{}}{}?{{}}", state.base_url, serde_qs::to_string(&query).unwrap())) + .header("Authorization", format!("Token {{}}", state.authentication_token)){} + .send(); \ No newline at end of file diff --git a/src/templates/usings.template b/src/templates/usings.template index dfa5103..cb5f06d 100644 --- a/src/templates/usings.template +++ b/src/templates/usings.template @@ -1,4 +1,6 @@ +use crate::util::ThanixClient; use serde_qs; use serde_json; -use reqwest::Url; -use crate::util::ThanixClient; +use reqwest::blocking::Response; +use reqwest::Error; + From f6c3fff56eb889cba424f786249efbb9e1b4a68f Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 25 Feb 2024 22:18:17 +0100 Subject: [PATCH 02/10] Fix readme being written to wrong dir --- src/bindgen.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bindgen.rs b/src/bindgen.rs index d70f763..b813bca 100644 --- a/src/bindgen.rs +++ b/src/bindgen.rs @@ -93,7 +93,10 @@ fn create_lib_dir(output_path: &Path) -> io::Result<()> { )?; // Create the "README.md" file. - fs::write("README.md", include_str!("templates/README.md.template"))?; + fs::write( + output_path.join("README.md"), + include_str!("templates/README.md.template"), + )?; println!("Output successfully repackaged!"); Ok(()) From 29ca379e02000a82aa3f08508efbaa91d988c11d Mon Sep 17 00:00:00 2001 From: ByteOtter Date: Sun, 25 Feb 2024 22:33:12 +0100 Subject: [PATCH 03/10] update version number in Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index fe1757a..e939b0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "thanix" authors = ["Christopher Hock "] -version = "0.1.0-alpha.9" +version = "0.1.0-alpha.10" publish = true edition = "2021" description = "A yaml-to-rust code generator for generating Rust code from yaml config files e.g. as found in openAPI." From a9aff37032a4c3b2f5c331fb1baaec3d8f1d12ba Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 25 Feb 2024 23:36:18 +0100 Subject: [PATCH 04/10] Add integer limit matching --- src/bindgen.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/bindgen.rs b/src/bindgen.rs index b813bca..5ffad8a 100644 --- a/src/bindgen.rs +++ b/src/bindgen.rs @@ -123,7 +123,39 @@ pub fn type_to_string(ty: &ReferenceOr) -> String { SchemaKind::Type(t) => match t { Type::String(_) => "String".to_owned(), Type::Number(_) => "f64".to_owned(), - Type::Integer(_) => "i64".to_owned(), + Type::Integer(int) => { + let signed = match int.minimum { + Some(x) => x < 0, + None => false, + }; + let int_size = match int.maximum { + Some(x) => { + if signed { + if x <= i8::MAX.into() { + "i8" + } else if x <= i16::MAX.into() { + "i16" + } else if x <= i32::MAX.into() { + "i32" + } else { + "i64" + } + } else { + if x <= u8::MAX.into() { + "u8" + } else if x <= u16::MAX.into() { + "u16" + } else if x <= u32::MAX.into() { + "u32" + } else { + "u64" + } + } + } + None => "i64", + }; + return int_size.to_owned(); + } // JSON object, but Rust has no easy way to support this, so just ask for a string. Type::Object(_) => "String".to_owned(), Type::Boolean(_) => "bool".to_owned(), From 92e321dd94c3386b77f2e95bbd116be8d79a0301 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 25 Feb 2024 23:36:31 +0100 Subject: [PATCH 05/10] Add JSON body params --- src/pathgen.rs | 75 +++++++++++++++++++++++++++-------- src/structgen.rs | 2 +- src/templates/path.template | 5 +-- src/templates/usings.template | 1 + 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/pathgen.rs b/src/pathgen.rs index 361fad4..e5341d3 100644 --- a/src/pathgen.rs +++ b/src/pathgen.rs @@ -48,7 +48,7 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { .clone() .unwrap_or(make_fn_name_from_path(name) + "_" + op_type); - let mut fn_struct_params = Vec::new(); + let mut fn_query_params = Vec::new(); let mut fn_header_params = Vec::new(); let mut fn_path_params = Vec::new(); @@ -68,7 +68,7 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { _ => continue, }; // Format as a struct field. - fn_struct_params.push(format!( + fn_query_params.push(format!( "{}\t{}: Option<{}>,\n", make_comment(parameter_data.description, 1), parameter_data.name.into_safe(), @@ -109,13 +109,27 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { } } - // Build the query struct for this function. - let fn_struct_name = fn_name.to_case(Case::Pascal) + "Query"; - let fn_struct = format!( - "#[derive(Serialize, Deserialize, Debug)]\npub struct {} {{\n{}\n}}\n", - fn_struct_name, - fn_struct_params.into_iter().collect::() - ); + // Build the request body. + let fn_request_type = match &op.request_body { + Some(req) => match req { + ReferenceOr::Item(x) => match x.content.get("application/json") { + Some(media) => Some(bindgen::type_to_string(&media.schema.clone().unwrap())), + None => None, + }, + _ => None, + }, + None => None, + }; + + let fn_request_args = match &fn_request_type { + Some(x) => format!(", body: {}", x), + None => String::new(), + }; + + let fn_request_body = match &fn_request_type { + Some(_) => format!(".json(&body)"), + None => String::new(), + }; // Build the header args. let fn_header_args = &fn_header_params @@ -129,19 +143,48 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { .map(|(name, _)| format!("\n.header(\"{}\", header_{})", &name, &name)) .collect::(); + // Build the function args. + let fn_path_args = &fn_path_params.into_iter().collect::(); + + // Build the query struct for this function if we have at least one parameter. + let need_query = fn_query_params.len() > 0; + let fn_query_name = fn_name.to_case(Case::Pascal) + "Query"; + let fn_query_struct = if need_query { + format!( + "#[derive(Serialize, Deserialize, Debug)]\npub struct {} {{\n{}\n}}\n", + fn_query_name, + fn_query_params.clone().into_iter().collect::() + ) + } else { + String::new() + }; + let fn_query_args = if need_query { + format!(", query: {}", fn_query_name) + } else { + String::new() + }; + let fn_query_body = if need_query { + ", serde_qs::to_string(&query).unwrap()" + } else { + ", \"\"" + }; + // Build the function body. let fn_body = format!( include_str!("templates/path.template"), - op_type, name, fn_header + op_type, name, fn_query_body, fn_header, fn_request_body ); - // Build the function args. - let fn_path_args = fn_path_params.into_iter().collect::(); - - // TODO format!( - "{}{}pub fn {}(state: &ThanixClient, query: {}{}{}) -> Result {{\n{}\n}}\n", - fn_struct, description, fn_name, fn_struct_name, fn_header_args, fn_path_args, fn_body + "{}{}pub fn {}(state: &ThanixClient{}{}{}{}) -> Result {{\n{}\n}}\n", + fn_query_struct, + description, + fn_name, + fn_query_args, + fn_request_args, + fn_header_args, + fn_path_args, + fn_body ) } diff --git a/src/structgen.rs b/src/structgen.rs index 3b8e4fa..0cbcb39 100644 --- a/src/structgen.rs +++ b/src/structgen.rs @@ -14,7 +14,7 @@ pub fn gen(name: &str, schema: &Schema) -> Option { }; // Assemble struct string. - let mut result = "struct ".to_owned(); + let mut result = "#[derive(Serialize, Deserialize, Debug)]\npub struct ".to_owned(); result += name; result += " {\n"; diff --git a/src/templates/path.template b/src/templates/path.template index 42348dd..e4946d2 100644 --- a/src/templates/path.template +++ b/src/templates/path.template @@ -1,4 +1 @@ - return state.client - .{}(format!("{{}}{}?{{}}", state.base_url, serde_qs::to_string(&query).unwrap())) - .header("Authorization", format!("Token {{}}", state.authentication_token)){} - .send(); \ No newline at end of file + return state.client.{}(format!("{{}}{}?{{}}", state.base_url{})).header("Authorization", format!("Token {{}}", state.authentication_token)){}{}.send(); \ No newline at end of file diff --git a/src/templates/usings.template b/src/templates/usings.template index cb5f06d..21cf7b1 100644 --- a/src/templates/usings.template +++ b/src/templates/usings.template @@ -1,4 +1,5 @@ use crate::util::ThanixClient; +use crate::types::*; use serde_qs; use serde_json; use reqwest::blocking::Response; From 73f53f57dce5c9d86ff5b7b8d2f0cb6877e73388 Mon Sep 17 00:00:00 2001 From: ByteOtter Date: Sun, 25 Feb 2024 23:56:22 +0100 Subject: [PATCH 06/10] update version number in cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index bbd98da..8e5ee54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "thanix" -version = "0.1.0-alpha.9" +version = "0.1.0-alpha.10" dependencies = [ "check_keyword", "clap", From 1e53ed783d033c73f94d07957cec5d00379dd0b3 Mon Sep 17 00:00:00 2001 From: ByteOtter Date: Mon, 26 Feb 2024 00:20:21 +0100 Subject: [PATCH 07/10] make all typ fields public --- src/structgen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/structgen.rs b/src/structgen.rs index 0cbcb39..1ccdc1a 100644 --- a/src/structgen.rs +++ b/src/structgen.rs @@ -31,7 +31,7 @@ pub fn gen(name: &str, schema: &Schema) -> Option { } } result += "\t"; - result += &prop_name.clone().into_safe(); + result += &format!("pub {}", &prop_name.clone().into_safe()); result += ": "; result += &type_name; result += ",\n"; From 46354d89bc36267c7405e7f220b8bc0de4fe3c71 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 26 Feb 2024 01:26:37 +0100 Subject: [PATCH 08/10] Switch to string builder, Response enum deserialization --- src/pathgen.rs | 154 +++++++++++++++++++++------------- src/structgen.rs | 2 +- src/templates/usings.template | 2 - 3 files changed, 95 insertions(+), 63 deletions(-) diff --git a/src/pathgen.rs b/src/pathgen.rs index e5341d3..5d0bf36 100644 --- a/src/pathgen.rs +++ b/src/pathgen.rs @@ -35,12 +35,7 @@ pub fn gen(name: &str, path_item: &PathItem) -> Option { } fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { - // Build description. - let mut description = op.description.clone().unwrap_or(String::new()); - description = bindgen::make_comment( - Some(format!("`{}`\n{}", op_type.to_uppercase(), description)), - 0, - ); + let mut result = String::new(); // Build function name. let fn_name = op @@ -121,71 +116,110 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { None => None, }; - let fn_request_args = match &fn_request_type { - Some(x) => format!(", body: {}", x), - None => String::new(), - }; + // Build the query struct for this function if we have at least one parameter. + let need_query = fn_query_params.len() > 0; + let fn_query_name = fn_name.to_case(Case::Pascal) + "Query"; + let fn_query_struct = format!( + "#[derive(Serialize, Deserialize, Debug, Default)]\npub struct {} {{\n{}\n}}\n", + fn_query_name, + fn_query_params.clone().into_iter().collect::() + ); - let fn_request_body = match &fn_request_type { - Some(_) => format!(".json(&body)"), - None => String::new(), - }; + if need_query { + result += &fn_query_struct; + } + + // Build the response enum. + let fn_response_name = fn_name.to_case(Case::Pascal) + "Response"; + result += "#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(untagged)]\npub enum "; + result += &fn_response_name; + result += " {\n"; + + for (status, response) in &op.responses.responses { + result += "\t"; + match response { + ReferenceOr::Item(x) => { + result += &format!("Http{}", status); + if let Some(y) = &x.content.get("application/json") { + result += "("; + result += &bindgen::type_to_string(&y.schema.as_ref().unwrap()); + result += ")"; + } + result += ",\n"; + } + _ => (), + } + } + + result += "\t#[default]\n"; + result += "\tNone\n"; + result += "}\n"; + + // Build function description. + result += &bindgen::make_comment(op.description.clone(), 0); + + // Build function declaration. + result += "pub fn "; + result += &fn_name; + result += "(state: &ThanixClient"; + + // Build the query arg. + if need_query { + result += ", query: "; + result += &fn_query_name; + } + + // Build the JSON arg. + if let Some(x) = &fn_request_type { + result += ", body: "; + result += x; + } + + // Build the path args. + result += &fn_path_params.into_iter().collect::(); // Build the header args. - let fn_header_args = &fn_header_params + result += &fn_header_params .iter() .map(|(name, ty)| format!(", header_{}: {}", name, ty)) .collect::(); - // Build the header calls. - let fn_header = &fn_header_params - .iter() - .map(|(name, _)| format!("\n.header(\"{}\", header_{})", &name, &name)) - .collect::(); - - // Build the function args. - let fn_path_args = &fn_path_params.into_iter().collect::(); + result += ") -> "; - // Build the query struct for this function if we have at least one parameter. - let need_query = fn_query_params.len() > 0; - let fn_query_name = fn_name.to_case(Case::Pascal) + "Query"; - let fn_query_struct = if need_query { - format!( - "#[derive(Serialize, Deserialize, Debug)]\npub struct {} {{\n{}\n}}\n", - fn_query_name, - fn_query_params.clone().into_iter().collect::() - ) - } else { - String::new() - }; - let fn_query_args = if need_query { - format!(", query: {}", fn_query_name) - } else { - String::new() - }; - let fn_query_body = if need_query { - ", serde_qs::to_string(&query).unwrap()" - } else { - ", \"\"" - }; + // Build the response type. + result += "Result<"; + result += &fn_response_name; + result += ", Error>"; // Build the function body. - let fn_body = format!( - include_str!("templates/path.template"), - op_type, name, fn_query_body, fn_header, fn_request_body - ); + result += " {\n\tstate.client."; + result += op_type; + result += "(format!(\"{}"; + result += name; + if need_query { + result += "?{}"; + } + result += "\", state.base_url"; + if need_query { + result += ", serde_qs::to_string(&query).unwrap()"; + } + result += "))\n"; + + // Auth header. + result += "\t\t.header(\"Authorization\", format!(\"Token {}\", state.authentication_token))\n"; + + // JSON body. + if let Some(_) = &fn_request_type { + result += "\t\t.json(&body)\n"; + } + fn_header_params + .iter() + .for_each(|(name, _)| result += &format!("\n.header(\"{}\", header_{})", &name, &name)); + result += "\t\t.send()?\n"; + result += "\t\t.json()\n"; + result += "}\n"; - format!( - "{}{}pub fn {}(state: &ThanixClient{}{}{}{}) -> Result {{\n{}\n}}\n", - fn_query_struct, - description, - fn_name, - fn_query_args, - fn_request_args, - fn_header_args, - fn_path_args, - fn_body - ) + return result; } fn make_fn_name_from_path(input: &str) -> String { diff --git a/src/structgen.rs b/src/structgen.rs index 1ccdc1a..3361472 100644 --- a/src/structgen.rs +++ b/src/structgen.rs @@ -14,7 +14,7 @@ pub fn gen(name: &str, schema: &Schema) -> Option { }; // Assemble struct string. - let mut result = "#[derive(Serialize, Deserialize, Debug)]\npub struct ".to_owned(); + let mut result = "#[derive(Serialize, Deserialize, Debug, Default)]\npub struct ".to_owned(); result += name; result += " {\n"; diff --git a/src/templates/usings.template b/src/templates/usings.template index 21cf7b1..8935b5c 100644 --- a/src/templates/usings.template +++ b/src/templates/usings.template @@ -1,7 +1,5 @@ use crate::util::ThanixClient; use crate::types::*; use serde_qs; -use serde_json; -use reqwest::blocking::Response; use reqwest::Error; From c45bc8a3db9793e2e43e41f27f63f54dcbb20a42 Mon Sep 17 00:00:00 2001 From: Marvin Friedrich Date: Mon, 26 Feb 2024 11:46:36 +0100 Subject: [PATCH 09/10] Correctly handle response codes --- src/pathgen.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/pathgen.rs b/src/pathgen.rs index 5d0bf36..284ca8f 100644 --- a/src/pathgen.rs +++ b/src/pathgen.rs @@ -192,7 +192,7 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { result += ", Error>"; // Build the function body. - result += " {\n\tstate.client."; + result += " {\n\tlet r#response = state.client."; result += op_type; result += "(format!(\"{}"; result += name; @@ -215,9 +215,31 @@ fn gen_fn(name: &str, op_type: &str, op: &Operation) -> String { fn_header_params .iter() .for_each(|(name, _)| result += &format!("\n.header(\"{}\", header_{})", &name, &name)); - result += "\t\t.send()?\n"; - result += "\t\t.json()\n"; - result += "}\n"; + result += "\t\t.send()?;\n"; + result += "\tmatch r#response.status().as_u16() {\n"; + + // Match response code. + for (status, response) in &op.responses.responses { + match response { + ReferenceOr::Item(x) => { + if let Some(y) = &x.content.get("application/json") { + result += &format!( + "\t\t{} => {{ Ok({}::Http{}(r#response.json::<{}>()?)) }},\n", + status, + &fn_response_name, + status, + &bindgen::type_to_string(&y.schema.as_ref().unwrap()) + ); + } + } + _ => (), + } + } + + // Unknown response code. + result += "\t\t_ => { Ok("; + result += &fn_response_name; + result += "::None) }\n\t}\n}\n"; return result; } From e8593c3d05abacd8a1450cb72266913b75b0fe1a Mon Sep 17 00:00:00 2001 From: ByteOtter Date: Mon, 26 Feb 2024 13:26:41 +0100 Subject: [PATCH 10/10] update version number in specfile --- Thanix.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Thanix.spec b/Thanix.spec index f07e12f..d97e1f0 100644 --- a/Thanix.spec +++ b/Thanix.spec @@ -17,7 +17,7 @@ Name: Thanix -Version: 0.1.0_alpha.9 +Version: 0.1.0_alpha.10 Release: 0.1 Summary: Rust to yaml code generator. # FIXME: Select a correct license from https://github.com/openSUSE/spec-cleaner#spdx-licenses