From 865dbd4be4beee4781ffaa560184423a949299d1 Mon Sep 17 00:00:00 2001 From: Adam Leventhal Date: Sat, 2 Oct 2021 11:40:03 -0700 Subject: [PATCH] migrate to typify and use quote (#4) --- Cargo.lock | 439 +++++++++++++-- Cargo.toml | 12 +- src/main.rs | 1357 +++++++++++----------------------------------- src/template.rs | 49 +- src/to_schema.rs | 523 ++++++++++++++++++ 5 files changed, 1291 insertions(+), 1089 deletions(-) create mode 100644 src/to_schema.rs diff --git a/Cargo.lock b/Cargo.lock index 4ba87a4..326af4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,10 +3,19 @@ version = 3 [[package]] -name = "anyhow" -version = "1.0.41" +name = "aho-corasick" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15af2628f6890fe2609a3b91bef4c83450512802e59489f9c1cb1fa5df064a61" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" [[package]] name = "autocfg" @@ -14,12 +23,36 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "dtoa" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" +[[package]] +name = "dyn-clone" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee2626afccd7561a06cf1367e2950c4718ea04565e20fb5029b6c7d8ad09abcf" + [[package]] name = "getopts" version = "0.2.21" @@ -30,16 +63,36 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.9.1" +name = "getrandom" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "home" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2456aef2e6b6a9784192ae780c0f15bc57df0e918585282325e8c8ac27737654" +dependencies = [ + "winapi", +] [[package]] name = "indexmap" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3" +checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" dependencies = [ "autocfg", "hashbrown", @@ -48,9 +101,21 @@ dependencies = [ [[package]] name = "itoa" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6" [[package]] name = "linked-hash-map" @@ -59,10 +124,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" [[package]] -name = "openapiv3" -version = "1.0.0-beta.1" +name = "memchr" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe6e1dfe8bc4931d6e90adbe2cec19e3fd67f7453d64e9a17cbf8dc16a67ec9" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "openapiv3" +version = "1.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791f5f66415ca5d11fb29e8dcf0b923adff5b2c9bd1cf54621777494c8f8f688" dependencies = [ "indexmap", "serde", @@ -71,10 +142,25 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.27" +name = "pest" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "proc-macro2" +version = "1.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d" dependencies = [ "unicode-xid", ] @@ -85,9 +171,15 @@ version = "0.0.0" dependencies = [ "anyhow", "getopts", + "indexmap", "openapiv3", + "proc-macro2", + "quote", + "rustfmt-wrapper", + "schemars", "serde", "serde_json", + "typify", ] [[package]] @@ -99,6 +191,91 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "rustfmt-wrapper" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/typify#825df8c1192f91f9edd27de0b81a858514d1865a" +dependencies = [ + "tempfile", + "thiserror", + "toolchain_find", +] + [[package]] name = "ryu" version = "1.0.5" @@ -106,19 +283,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" [[package]] -name = "serde" -version = "1.0.126" +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a48d098c2a7fdf5740b19deb1181b4fb8a9e68e03ae517c14cde04b5725409" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9ea2a613fe4cd7118b2bb101a25d8ae6192e1975179b67b2f17afd11e70ac8" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.126" +version = "1.0.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dbab34ca63057a1f15280bdf3c39f2b1eb1b54c17e98360e511637aef7418c6" dependencies = [ "proc-macro2", "quote", @@ -127,9 +366,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.64" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +checksum = "0f690853975602e1bfe1ccbf50504d67174e3bcf340f23b5ea9992e0587a52d8" dependencies = [ "itoa", "ryu", @@ -138,21 +377,21 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.8.17" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23" +checksum = "d8c608a35705a5d3cdc9fbe403147647ff34b921f8e833e49306df898f9b20af" dependencies = [ "dtoa", - "linked-hash-map", + "indexmap", "serde", "yaml-rust", ] [[package]] name = "syn" -version = "1.0.73" +version = "1.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7" +checksum = "5239bc68e0fef57495900cfea4e8dc75596d9a319d7e16b1e0a440d24e6fe0a0" dependencies = [ "proc-macro2", "quote", @@ -160,10 +399,100 @@ dependencies = [ ] [[package]] -name = "unicode-width" -version = "0.1.8" +name = "tempfile" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if", + "libc", + "rand", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602eca064b2d83369e2b2f34b09c70b605402801927c65c11071ac911d299b88" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad553cc2c78e8de258400763a647e80e6d1b31ee237275d756f6836d204494c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toolchain_find" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e85654a10e7a07a47c6f19d93818f3f343e22927f2fa280c84f7c8042743413" +dependencies = [ + "home", + "lazy_static", + "regex", + "semver", + "walkdir", +] + +[[package]] +name = "typify" +version = "0.0.1" +source = "git+https://github.com/oxidecomputer/typify#825df8c1192f91f9edd27de0b81a858514d1865a" +dependencies = [ + "rustfmt-wrapper", + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.0.1" +source = "git+https://github.com/oxidecomputer/typify#825df8c1192f91f9edd27de0b81a858514d1865a" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "schemars", + "serde_json", + "syn", + "thiserror", +] + +[[package]] +name = "typify-macro" +version = "0.0.1" +source = "git+https://github.com/oxidecomputer/typify#825df8c1192f91f9edd27de0b81a858514d1865a" +dependencies = [ + "proc-macro2", + "quote", + "schemars", + "serde_json", + "syn", + "typify-impl", +] + +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" [[package]] name = "unicode-xid" @@ -171,6 +500,54 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index cac6817..0821af4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,12 @@ description = "An OpenAPI client generator" [dependencies] anyhow = "1" getopts = "0.2" -serde = { version = "1", features = [ "derive" ]} -serde_json = "1" -openapiv3 = "1.0.0-beta.1" +indexmap = "1.7.0" +openapiv3 = "1.0.0-beta.2" +proc-macro2 = "1.0.29" +quote = "1.0.9" +rustfmt-wrapper = { git = "https://github.com/oxidecomputer/typify" } +schemars = "0.8.5" +serde = { version = "1", features = [ "derive" ] } +serde_json = "1.0.68" +typify = { git = "https://github.com/oxidecomputer/typify" } diff --git a/src/main.rs b/src/main.rs index 46a64b9..01da9fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,14 +2,22 @@ #![allow(clippy::single_match)] use anyhow::{anyhow, bail, Context, Result}; -use openapiv3::OpenAPI; +use openapiv3::{OpenAPI, ReferenceOr, Schema, SchemaData, SchemaKind}; use serde::Deserialize; +use std::cell::Ref; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +use quote::{format_ident, quote}; +use typify::TypeSpace; + +use crate::to_schema::ToSchema; + mod template; +mod to_schema; fn save

(p: P, data: &str) -> Result<()> where @@ -113,32 +121,44 @@ where * Make sure every operation has an operation ID, and that each * operation ID is only used once in the document. */ + let mut id = |o: Option<&openapiv3::Operation>| -> Result<()> { + if let Some(o) = o { + if let Some(oid) = o.operation_id.as_ref() { + if !opids.insert(oid.to_string()) { + bail!("duplicate operation ID: {}", oid); + } - for o in item.iter() { - if let Some(oid) = o.operation_id.as_ref() { - if !opids.insert(oid.to_string()) { - bail!("duplicate operation ID: {}", oid); - } + if !o.tags.is_empty() { + bail!("op {}: tags, unsupported", oid); + } - if !o.tags.is_empty() { - bail!("op {}: tags, unsupported", oid); - } + if !o.servers.is_empty() { + bail!("op {}: servers, unsupported", oid); + } - if !o.servers.is_empty() { - bail!("op {}: servers, unsupported", oid); - } + if o.security.is_some() { + bail!("op {}: security, unsupported", oid); + } - if o.security.is_some() { - bail!("op {}: security, unsupported", oid); + if o.responses.default.is_some() { + bail!("op {}: has response default", oid); + } + } else { + bail!("path {} is missing operation ID", p.0); } - - if o.responses.default.is_some() { - bail!("op {}: has response default", oid); - } - } else { - bail!("path {} is missing operation ID", p.0); } - } + + Ok(()) + }; + + id(item.get.as_ref())?; + id(item.put.as_ref())?; + id(item.post.as_ref())?; + id(item.delete.as_ref())?; + id(item.options.as_ref())?; + id(item.head.as_ref())?; + id(item.patch.as_ref())?; + id(item.trace.as_ref())?; if !item.servers.is_empty() { bail!("path {} has servers; unsupported", p.0); @@ -471,780 +491,22 @@ impl PartialEq for TypeId { } } -enum UseContext { - Module, - Return, - Parameter, +enum ParamType { + Path, + Query, + Body, } -#[allow(dead_code)] -impl UseContext { - fn is_module(&self) -> bool { - matches!(self, &UseContext::Module) - } - - fn is_return(&self) -> bool { - matches!(self, &UseContext::Return) - } - - fn is_parameter(&self) -> bool { - matches!(self, &UseContext::Parameter) - } -} - -#[derive(Debug)] -struct TypeSpace { - next_id: u64, - /* - * Object types generally have a useful name, which we would like to match - * with anywhere that name appears in the definition document. Many other - * types, though, do not; e.g., an array of strings is just going to become - * Vec without necesssarily having a useful distinct type name. - */ - name_to_id: BTreeMap, - id_to_entry: BTreeMap, - ref_to_id: BTreeMap, - - import_chrono: bool, - import_uuid: bool, -} - -impl TypeSpace { - fn new() -> TypeSpace { - TypeSpace { - next_id: 1, - name_to_id: BTreeMap::new(), - id_to_entry: BTreeMap::new(), - ref_to_id: BTreeMap::new(), - import_chrono: false, - import_uuid: false, - } - } - - fn is_optional(&self, tid: &TypeId) -> bool { - if let Some(te) = self.id_to_entry.get(&tid) { - if let TypeDetails::Optional(_) = &te.details { - return true; - } - } - false - } - - /** - * Emit a human-readable diagnostic description for this type ID. - */ - fn describe(&self, tid: &TypeId) -> String { - if let Some(te) = self.id_to_entry.get(&tid) { - match &te.details { - TypeDetails::Basic => { - if let Some(n) = &te.name { - n.to_string() - } else { - format!("[BASIC {} !NONAME?]", tid.0) - } - } - TypeDetails::Dictionary(itid) => { - if let Some(ite) = self.id_to_entry.get(&itid) { - if let Some(n) = &ite.name { - return format!("dictionary of {} <{}>", n, itid.0); - } - } - - /* - * If there is no name attached, we should try a - * recursive describe. - */ - format!("dictionary of {}", self.describe(itid)) - } - TypeDetails::Array(itid) => { - if let Some(ite) = self.id_to_entry.get(&itid) { - if let Some(n) = &ite.name { - return format!("array of {} <{}>", n, itid.0); - } - } - - /* - * If there is no name attached, we should try a - * recursive describe. - */ - format!("array of {}", self.describe(itid)) - } - TypeDetails::Optional(itid) => { - if let Some(ite) = self.id_to_entry.get(&itid) { - if let Some(n) = &ite.name { - return format!("option of {} <{}>", n, itid.0); - } - } - - /* - * If there is no name attached, we should try a - * recursive describe. - */ - format!("option of {}", self.describe(itid)) - } - TypeDetails::Object(_) => { - if let Some(n) = &te.name { - format!("object {}", n) - } else { - format!("[OBJECT {} !NONAME?]", tid.0) - } - } - TypeDetails::NewType(itid) => { - if let Some(ite) = self.id_to_entry.get(&itid) { - if let Some(n) = &ite.name { - return format!("newtype of {} <{}>", n, itid.0); - } - } - - /* - * If there is no name attached, we should try a - * recursive describe. - */ - format!("newtype of {}", self.describe(itid)) - } - TypeDetails::Enumeration(_) => { - if let Some(n) = &te.name { - format!("enum {}", n) - } else { - format!("[ENUMERATION {} !NONAME?]", tid.0) - } - } - TypeDetails::Unknown => { - format!("[UNKNOWN {}]", tid.0) - } - } - } else { - format!("[UNMAPPED {}]", tid.0) - } - } - - fn render_type(&self, tid: &TypeId, ctx: UseContext) -> Result { - let in_mod = ctx.is_module(); - - if let Some(te) = self.id_to_entry.get(&tid) { - match &te.details { - TypeDetails::Basic => { - if let Some(n) = &te.name { - Ok(if n == "String" && ctx.is_parameter() { - "&str" - } else { - n - } - .to_string()) - } else { - bail!("basic type {:?} does not have a name?", tid); - } - } - TypeDetails::Array(itid) => { - Ok(format!("Vec<{}>", self.render_type(itid, ctx)?)) - } - TypeDetails::Dictionary(itid) => Ok(format!( - "std::collections::HashMap", - self.render_type(itid, ctx)? - )), - TypeDetails::Optional(itid) => { - Ok(format!("Option<{}>", self.render_type(itid, ctx)?)) - } - TypeDetails::Object(_) - | TypeDetails::NewType(_) - | TypeDetails::Enumeration(_) => { - if let Some(n) = &te.name { - if in_mod { - Ok(n.to_string()) - } else { - /* - * Model types are declared in the "types" module, - * and must be referenced with that prefix when not - * in the module itself. - */ - Ok(format!("types::{}", n.to_string())) - } - } else { - bail!("object type {:?} does not have a name?", tid); - } - } - TypeDetails::Unknown => { - bail!("type {:?} is unknown", tid); - } - } - } else { - bail!("could not resolve type ID {:?}", tid); - } - } - - fn assign(&mut self) -> TypeId { - let id = TypeId(self.next_id); - self.next_id += 1; - id - } - - fn id_for_name(&mut self, name: &str) -> TypeId { - let id = if let Some(id) = self.name_to_id.get(name) { - id.clone() - } else { - let id = self.assign(); - self.name_to_id.insert(name.to_string(), id.clone()); - id - }; - id - } - - fn id_for_optional(&mut self, want: &TypeId) -> TypeId { - for (oid, oent) in self.id_to_entry.iter() { - match &oent.details { - TypeDetails::Optional(id) if id == want => return oid.clone(), - _ => continue, - } - } - - let oid = self.assign(); - self.id_to_entry.insert( - oid.clone(), - TypeEntry { - id: oid.clone(), - name: None, - details: TypeDetails::Optional(want.clone()), - }, - ); - oid - } - - fn prepop_reference(&mut self, name: &str, r: &str) -> Result<()> { - let id = self.id_for_name(name); - if let Some(rid) = self.ref_to_id.get(r) { - if rid != &id { - bail!( - "duplicate ref {:?}, name, {:?}, id {:?}, rid {:?}", - r, - name, - id, - rid - ); - } - } else { - self.ref_to_id.insert(r.to_string(), id); - } - Ok(()) - } - - fn select_ref(&mut self, _name: Option<&str>, r: &str) -> Result { - /* - * As this is a reference, all we can do for now is determine - * the type ID. - */ - Ok(if let Some(id) = self.ref_to_id.get(r) { - id.clone() - } else { - let id = self.assign(); - self.ref_to_id.insert(r.to_string(), id.clone()); - id - }) - } - - fn select_schema( - &mut self, - name: Option<&str>, - s: &openapiv3::Schema, - ) -> Result { - let (name, details) = match &s.schema_kind { - openapiv3::SchemaKind::Type(t) => match t { - openapiv3::Type::Array(at) => { - if at.items.is_none() { - bail!("array items can't be none"); - } - /* - * Determine the type of item that will be in this array: - */ - let itid = - self.select_box(None, at.items.as_ref().unwrap())?; - (None, TypeDetails::Array(itid)) - } - openapiv3::Type::Object(o) => { - /* - * Object types must have a consistent name. - */ - let name = match (name, s.schema_data.title.as_deref()) { - (Some(n), None) => Some(n.to_string()), - (None, Some(t)) => Some(t.to_string()), - (Some(n), Some(t)) if n == t => Some(n.to_string()), - (Some(n), Some(t)) => { - bail!("names {} and {} conflict", n, t) - } - (None, None) => None, - }; - - if let Some(name) = name { - let mut omap = BTreeMap::new(); - for (n, rb) in o.properties.iter() { - let itid = self.select_box(None, &rb)?; - if o.required.contains(n) { - omap.insert(n.to_string(), itid); - } else { - /* - * This is an optional member. - */ - omap.insert( - n.to_string(), - self.id_for_optional(&itid), - ); - } - } - (Some(name), TypeDetails::Object(omap)) - } else { - /* - * An object type without a name may be intended as a - * map from strings to something else. If we also have - * no properties, required or otherwise, and we have an - * "additionalProperties" schema, we may be able to - * represent this as a HashMap. - */ - use openapiv3::AdditionalProperties::Schema; - - let tid = if o.properties.is_empty() - && o.required.is_empty() - { - if let Some(Schema(s)) = &o.additional_properties { - Some(self.select(None, &s)?) - } else { - None - } - } else { - None - }; - - if let Some(tid) = tid { - (None, TypeDetails::Dictionary(tid)) - } else { - bail!("object types need a name? {:#?}", s); - } - } - } - openapiv3::Type::String(st) => { - use openapiv3::{ - StringFormat::DateTime, - VariantOrUnknownOrEmpty::{Empty, Item, Unknown}, - }; - - match &st.format { - Item(DateTime) => { - self.import_chrono = true; - ( - Some("DateTime".to_string()), - TypeDetails::Basic, - ) - } - Unknown(x) if x.as_str() == "uuid" => { - self.import_uuid = true; - (Some("Uuid".to_string()), TypeDetails::Basic) - } - Empty => { - use TypeDetails::{Enumeration, NewType}; - - if !st.enumeration.is_empty() { - if let Some(name) = name { - if st.enumeration.contains(&None) { - bail!( - "null found in enumeration values" - ); - } - ( - Some(name.to_string()), - Enumeration( - st.enumeration - .iter() - .map(|value| { - value.clone().unwrap() - }) - .collect(), - ), - ) - } else { - bail!("enumeration without name: {:?}", st); - } - } else if let Some(name) = name { - /* - * Create a newtype struct for strings that have - * a name of their own. - */ - let id = self.id_for_name("String"); - (Some(name.to_string()), NewType(id)) - } else { - (Some("String".to_string()), TypeDetails::Basic) - } - } - x => { - bail!("XXX string format {:?} {:?}", x, st); - } - } - } - openapiv3::Type::Boolean {} => { - (Some("bool".to_string()), TypeDetails::Basic) - } - openapiv3::Type::Number(_) => { - /* - * XXX - */ - (Some("f64".to_string()), TypeDetails::Basic) - } - openapiv3::Type::Integer(_) => { - /* - * XXX - */ - (Some("i64".to_string()), TypeDetails::Basic) - } - }, - x => { - bail!("unhandled schema kind: {:?}", x); - } - }; - - if let Some(name) = &name { - /* - * First, determine what ID we will use to identify this named type. - */ - let id = self.id_for_name(name.as_str()); - - /* - * If there is already an entry for this type ID, ensure that it - * matches the entry we have constructed. If there is not yet an - * entry, we can just keep this one. - */ - if let Some(et) = self.id_to_entry.get(&id) { - if et.details != details { - bail!("{:?} != {:?}", et.details, details); - } - } else { - self.id_to_entry.insert( - id.clone(), - TypeEntry { - id: id.clone(), - name: Some(name.clone()), - details, - }, - ); - } - - Ok(id) - } else { - /* - * If this type has no name, look for an existing unnamed type with - * the same shape. - */ - for (tid, te) in self.id_to_entry.iter() { - if te.name.is_none() && te.details == details { - return Ok(tid.clone()); - } - } - - /* - * Otherwise, insert a new entry. - */ - let tid = self.assign(); - self.id_to_entry.insert( - tid.clone(), - TypeEntry { - id: tid.clone(), - name: None, - details, - }, - ); - Ok(tid) - } - } - - fn select( - &mut self, - name: Option<&str>, - s: &openapiv3::ReferenceOr, - ) -> Result { - match s { - openapiv3::ReferenceOr::Reference { reference } => { - self.select_ref(name, reference.as_str()) - } - openapiv3::ReferenceOr::Item(s) => self.select_schema(name, s), - } - } - - fn select_box( - &mut self, - name: Option<&str>, - s: &openapiv3::ReferenceOr>, - ) -> Result { - match s { - openapiv3::ReferenceOr::Reference { reference } => { - self.select_ref(name, reference.as_str()) - } - openapiv3::ReferenceOr::Item(s) => { - self.select_schema(name, s.as_ref()) - } - } - } -} - -fn gen(api: &OpenAPI, ts: &mut TypeSpace) -> Result { - let mut out = String::new(); - - let mut a = |s: &str| { - out.push_str(s); - out.push('\n'); - }; - - /* - * Deal with any dependencies we require to produce this client. - */ - a(""); - a("use anyhow::Result;"); /* XXX */ - a(""); - - a("mod progenitor_support {"); - a(" use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};"); - a(""); - /* - * The percent-encoding crate abrogates its responsibility for providing - * useful percent-encoding sets, so we must provide one for path components - * here. - */ - a(" const PATH_SET: &AsciiSet = &CONTROLS"); - /* - * The query percent-encode set is the C0 control percent-encode set and - * U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>). - */ - a(" .add(b' ')"); - a(" .add(b'\"')"); - a(" .add(b'#')"); - a(" .add(b'<')"); - a(" .add(b'>')"); - /* - * The path percent-encode set is the query percent-encode set and U+003F - * (?), U+0060 (`), U+007B ({), and U+007D (}). - */ - a(" .add(b'?')"); - a(" .add(b'`')"); - a(" .add(b'{')"); - a(" .add(b'}');"); - a(""); - a(" pub(crate) fn encode_path(pc: &str) -> String {"); - a(" utf8_percent_encode(pc, PATH_SET).to_string()"); - a(" }"); - a("}"); - a(""); - - /* - * Declare named types we know about. We want the declarations to appear in - * a stable order within the file, so we first collect a list of type IDs - * that we can sort by their visible name. - */ - let mut ids = ts - .id_to_entry - .values() - .filter_map(|te| match &te.details { - TypeDetails::Object(_) - | TypeDetails::NewType(_) - | TypeDetails::Enumeration(_) => { - Some((te.name.as_deref().unwrap(), &te.id)) - } - TypeDetails::Basic - | TypeDetails::Unknown - | TypeDetails::Array(_) - | TypeDetails::Dictionary(_) - | TypeDetails::Optional(_) => None, - }) - .collect::>(); - ids.sort_by(|a, b| a.0.cmp(&b.0)); - - a("pub mod types {"); - if ts.import_chrono { - a(" use chrono::prelude::*;"); - } - if ts.import_uuid { - a(" use uuid::Uuid;"); - } - a(" use serde::{Serialize, Deserialize};"); - a(""); - for te in ids.iter().map(|x| ts.id_to_entry.get(x.1).unwrap()) { - match &te.details { - TypeDetails::Object(omap) => { - a(" #[derive(Serialize, Deserialize, Debug, Clone)]"); - a(&format!( - " pub struct {} {{", - te.name.as_deref().unwrap() - )); - for (name, tid) in omap.iter() { - if ts.is_optional(tid) { - /* - * Omit missing optionals from the document entirely; do - * not inject a "null" value. - */ - a(" #[serde(skip_serializing_if = \ - \"Option::is_none\")]"); - } - a(&format!( - " pub {}: {},", - name, - ts.render_type(tid, UseContext::Module)? - )); - } - a(" }"); - a(""); - } - TypeDetails::NewType(tid) => { - let n = te.name.as_deref().unwrap(); - a(" #[derive(Serialize, Deserialize, Debug, Clone)]"); - a(&format!( - " pub struct {}({});", - n, - ts.render_type(tid, UseContext::Module)?, - )); - a(""); - - /* - * Implement a basic Display trait so that to_string() works - * as well as it would have for the base type: - */ - a(&format!(" impl std::fmt::Display for {} {{", n)); - a(" fn fmt(&self, f: &mut std::fmt::Formatter) -> \ - std::fmt::Result {"); - a(" write!(f, \"{}\", self.0)"); - a(" }"); - a(" }"); - - a(""); - } - TypeDetails::Enumeration(list) => { - a(" #[derive(Serialize, Deserialize, Debug)]"); - a(&format!(" pub enum {} {{", te.name.as_deref().unwrap())); - for name in list.iter() { - /* - * Attempt to make the first letter a capital letter. - */ - let mut name = name.chars().collect::>(); - if name[0].is_ascii_alphabetic() { - name[0] = name[0].to_ascii_uppercase(); - } - - a( - &format!( - " {},", - name.iter().collect::(), - ), - ); - } - a(" }"); - a(""); - } - x => panic!("unexpected type details here: {:?}", x), - } - } - a("}"); - a(""); - - /* - * Declare the client object: - */ - a("pub struct Client {"); - a(" baseurl: String,"); - a(" client: reqwest::Client,"); - a("}"); - a(""); - - a("impl Client {"); - a(" pub fn new(baseurl: &str) -> Client {"); - a(" let dur = std::time::Duration::from_secs(15);"); - a(" let client = reqwest::ClientBuilder::new()"); - a(" .connect_timeout(dur)"); - a(" .timeout(dur)"); - a(" .build()"); - a(" .unwrap();"); - a(""); - a(" Client::new_with_client(baseurl, client)"); - a(" }"); - a(""); - a( - " pub fn new_with_client(baseurl: &str, client: reqwest::Client) \ - -> Client {", - ); - a(" Client {"); - a(" baseurl: baseurl.to_string(),"); - a(" client,"); - a(" }"); - a(" }"); - a(""); - - /* - * Generate a function for each Operation. - * - * XXX We should probably be producing an intermediate object for each of - * these, which can link in to the type space, instead of doing this inline - * here. - */ - for (pn, p) in api.paths.paths.iter() { - let op = p.item()?; - - let mut gen = |p: &str, - m: &str, - o: Option<&openapiv3::Operation>| - -> Result<()> { - let o = if let Some(o) = o { - o - } else { - return Ok(()); - }; - - let oid = o.operation_id.as_deref().unwrap(); - a(" /**"); - a(&format!(" * {}: {} {}", oid, m, p)); - a(" */"); - - let mut bounds: Vec = Vec::new(); +fn generate(api: &OpenAPI, ts: &mut TypeSpace) -> Result { + let methods = api + .operations() + .map(|(path, method, operation)| { let mut query: Vec<(String, bool)> = Vec::new(); - - let (body_param, body_func) = if let Some(b) = &o.request_body { - let b = b.item()?; - if b.is_binary()? { - bounds.push("B: Into".to_string()); - (Some("B".to_string()), Some("body".to_string())) - } else { - let mt = b - .content_json() - .with_context(|| anyhow!("{} {}", m, pn))?; - if !mt.encoding.is_empty() { - bail!("media type encoding not empty: {:#?}", mt); - } - - if let Some(s) = &mt.schema { - let tid = ts.select(None, s)?; - ( - Some(format!( - "&{}", - ts.render_type(&tid, UseContext::Return)? - )), - Some("json".to_string()), - ) - } else { - bail!("media type encoding, no schema: {:#?}", mt); - } - } - } else { - (None, None) - }; - - if bounds.is_empty() { - a(&format!(" pub async fn {}(", oid)); - } else { - a(&format!(" pub async fn {}<{}>(", oid, bounds.join(", "))); - } - a(" &self,"); - - /* - * The order of parameters in the specification is effectively - * arbitrary, and both path and query style parameters end up - * mingled in the same list. - */ - let mut parms = o + let mut raw_params = operation .parameters .iter() - .map(|par| { - match par.item()? { + .map(|parameter| { + match parameter.item()? { openapiv3::Parameter::Path { parameter_data, style: openapiv3::PathStyle::Simple, @@ -1254,12 +516,11 @@ fn gen(api: &OpenAPI, ts: &mut TypeSpace) -> Result { */ assert!(parameter_data.required); - let nam = ¶meter_data.name; - let tid = - ts.select(None, parameter_data.schema()?)?; - let typ = - ts.render_type(&tid, UseContext::Parameter)?; - return Ok((true, nam, typ)); + let nam = parameter_data.name.clone(); + let schema = parameter_data.schema()?.to_schema(); + let typ = ts.add_type(&schema)?; + + Ok((ParamType::Path, nam, quote! { &#typ })) } openapiv3::Parameter::Query { parameter_data, @@ -1273,65 +534,93 @@ fn gen(api: &OpenAPI, ts: &mut TypeSpace) -> Result { } } - let nam = ¶meter_data.name; - let tid = - ts.select(None, parameter_data.schema()?)?; - let tid = if parameter_data.required { - tid - } else { - /* - * If this is an optional parameter, we need an - * Option version of the type. - */ - ts.id_for_optional(&tid) - }; - let typ = - ts.render_type(&tid, UseContext::Parameter)?; + let nam = parameter_data.name.clone(); + let schema = parameter_data.schema()?.to_schema(); + let mut typ = ts.add_type(&schema)?; + if !parameter_data.required { + typ = quote! { Option<#typ> }; + } query.push(( nam.to_string(), !parameter_data.required, )); - return Ok((false, nam, typ)); + Ok((ParamType::Query, nam, quote! { &#typ })) } x => bail!("unhandled parameter type: {:#?}", x), } }) .collect::>>()?; - /* - * Deal first with path parameters, ordered by their position in the - * path template string. - */ - let tmp = template::parse(p)?; - for pn in tmp.names() { - let par = parms.iter().find(|p| p.0 && p.1 == &pn).unwrap(); - a(&format!(" {}: {},", par.1, par.2)); - } + let mut bounds = Vec::new(); - /* - * Second, include query parameters, ordered by parameter name. - */ - parms.sort_by(|a, b| a.1.cmp(&b.1)); - for par in parms.iter().filter(|p| !p.0) { - a(&format!(" {}: {},", par.1, par.2)); - } - - /* - * Include the body parameter, if there is one, last in the list: - */ - if let Some(bp) = &body_param { - a(&format!(" body: {},", bp)); - } - - let decode_response = if o.responses.responses.len() == 1 { - let only = o.responses.responses.iter().next().unwrap(); - match only.0 { - openapiv3::StatusCode::Code(n) => { - if *n < 200 || *n > 299 { - bail!("code? {:#?}", only); - } + let (body_param, body_func) = if let Some(b) = + &operation.request_body + { + let b = b.item()?; + if b.is_binary()? { + bounds.push(quote! {B: Into}); + (Some(quote! {B}), Some(quote! { .body(body) })) + } else { + let mt = b + .content_json() + .with_context(|| anyhow!("{} {}", method, path))?; + if !mt.encoding.is_empty() { + bail!("media type encoding not empty: {:#?}", mt); } - _ => bail!("code? {:#?}", only), + + if let Some(s) = &mt.schema { + let schema = s.to_schema(); + let typ = ts.add_type(&schema)?; + (Some(quote! { &#typ }), Some(quote! { .json(body) })) + } else { + bail!("media type encoding, no schema: {:#?}", mt); + } + } + } else { + (None, None) + }; + + if let Some(body) = body_param { + raw_params.push((ParamType::Body, "body".to_string(), body)); + } + + let tmp = template::parse(path)?; + let names = tmp.names(); + let url_path = tmp.compile(); + + // Put parameters in a deterministic order. + raw_params.sort_by(|a, b| match (&a.0, &b.0) { + // Path params are first and are in positional order. + (ParamType::Path, ParamType::Path) => { + let aa = names.iter().position(|x| x == &a.1).unwrap(); + let bb = names.iter().position(|x| x == &b.1).unwrap(); + aa.cmp(&bb) + } + (ParamType::Path, ParamType::Query) => Ordering::Less, + (ParamType::Path, ParamType::Body) => Ordering::Less, + + // Query params are in lexicographic order. + (ParamType::Query, ParamType::Body) => Ordering::Less, + (ParamType::Query, ParamType::Query) => a.1.cmp(&b.1), + (ParamType::Query, ParamType::Path) => Ordering::Greater, + + // Body params are last and should be unique + (ParamType::Body, ParamType::Path) => Ordering::Greater, + (ParamType::Body, ParamType::Query) => Ordering::Greater, + (ParamType::Body, ParamType::Body) => { + panic!("should only be one body") + } + }); + + let (response_type, decode_response) = if operation + .responses + .responses + .len() + == 1 + { + let only = operation.responses.responses.first().unwrap(); + if !matches!(only.0, openapiv3::StatusCode::Code(200..=299)) { + bail!("code? {:#?}", only); } let i = only.1.item()?; @@ -1342,29 +631,24 @@ fn gen(api: &OpenAPI, ts: &mut TypeSpace) -> Result { if !i.links.is_empty() { bail!("no response links for now"); } - /* - * XXX ignoring extensions. - */ /* * Look at the response content. For now, support a single * JSON-formatted response. */ - match (i.content.len(), i.content.get("application/json")) { - (0, _) => { - a(" ) -> Result<()> {"); - } + let typ = match ( + i.content.len(), + i.content.get("application/json"), + ) { + (0, _) => quote! { () }, (1, Some(mt)) => { if !mt.encoding.is_empty() { bail!("media type encoding not empty: {:#?}", mt); } - if let Some(s) = &mt.schema { - let tid = ts.select(None, s)?; - a(&format!( - " ) -> Result<{}> {{", - ts.render_type(&tid, UseContext::Return)? - )); + if let Some(schema) = &mt.schema { + let schema = schema.to_schema(); + ts.add_type(&schema)? } else { bail!("media type encoding, no schema: {:#?}", mt); } @@ -1375,88 +659,174 @@ fn gen(api: &OpenAPI, ts: &mut TypeSpace) -> Result { (_, _) => { bail!("too many response contents: {:#?}", i.content); } - } - true - } else if o.responses.responses.is_empty() { - a(" ) -> Result {"); - false + }; + (typ, quote! { res.json().await? }) + } else if operation.responses.responses.is_empty() { + (quote! { reqwest::Response }, quote! { res }) } else { - bail!("responses? {:#?}", o.responses); + bail!("responses? {:#?}", operation.responses); }; - /* - * Generate the URL for the request. - */ - a(&tmp.compile()); + let operation_id = + format_ident!("{}", operation.operation_id.as_deref().unwrap()); - /* - * If there is a query string, generate that now. - */ - if !query.is_empty() { - a(" let mut query = Vec::new();"); - for (qn, opt) in query.iter() { - if *opt { - a(&format!(" if let Some(v) = &{} {{", qn)); - a(&format!( - " \ - query.push((\"{}\".to_string(), v.to_string()));", - qn - )); - a(" }"); - } else { - a(&format!( - " \ - query.push((\"{}\".to_string(), {}.to_string()));", - qn, qn - )); - } - } - a(""); - } - - /* - * Perform the request. - */ - a(&format!( - " let res = self.client.{}(url)", - m.to_lowercase() - )); - if let Some(f) = &body_func { - a(&format!(" .{}(body)", f)); - } - if !query.is_empty() { - a(" .query(&query)"); - } - a(" .send()"); - a(" .await?"); - a(" .error_for_status()?;"); /* XXX */ - - a(""); - - if decode_response { - a(" Ok(res.json().await?)"); + let bounds = if bounds.is_empty() { + quote! {} } else { - a(" Ok(res)"); - } - a(" }"); - a(""); + quote! { + < #(#bounds),* > + } + }; - Ok(()) - }; + let params = raw_params.into_iter().map(|(_, name, typ)| { + let name = format_ident!("{}", name); + quote! { + #name: #typ + } + }); - gen(pn.as_str(), "GET", op.get.as_ref())?; - gen(pn.as_str(), "PUT", op.put.as_ref())?; - gen(pn.as_str(), "POST", op.post.as_ref())?; - gen(pn.as_str(), "DELETE", op.delete.as_ref())?; - gen(pn.as_str(), "OPTIONS", op.options.as_ref())?; - gen(pn.as_str(), "HEAD", op.head.as_ref())?; - gen(pn.as_str(), "PATCH", op.patch.as_ref())?; - gen(pn.as_str(), "TRACE", op.trace.as_ref())?; + let (query_build, query_use) = if query.is_empty() { + (quote! {}, quote! {}) + } else { + let query_items = query.iter().map(|(qn, opt)| { + if *opt { + let qn_ident = format_ident!("{}", qn); + quote! { + if let Some(v) = & #qn_ident { + query.push((#qn, v.to_string())); + } + } + } else { + quote! { + query.push((#qn, #qn.to_string())); + } + } + }); + + let query_build = quote! { + let mut query = Vec::new(); + #(#query_items)* + }; + let query_use = quote! { + .query(&query) + }; + + (query_build, query_use) + }; + + let doc_comment = format!( + "{}: {} {}", + operation.operation_id.as_deref().unwrap(), + method.to_ascii_uppercase(), + path + ); + + let method_func = format_ident!("{}", method); + + let method = quote! { + #[doc = #doc_comment] + pub async fn #operation_id #bounds ( + &self, + #(#params),* + ) -> Result<#response_type> { + #url_path + #query_build + + let res = self.client + . #method_func (url) + #body_func + #query_use + .send() + .await? + .error_for_status()?; + + Ok(#decode_response) + } + }; + + Ok(method) + }) + .collect::>>()?; + + let mut types = ts + .iter_types() + .map(|type_entry| (type_entry.type_name(ts), type_entry.output(ts))) + .collect::>(); + types.sort_by(|a, b| a.0.cmp(&b.0)); + let types = types.into_iter().map(|(_, def)| def); + + println!("-----------------------------------------------------"); + println!(" TYPE SPACE"); + println!("-----------------------------------------------------"); + for (idx, type_entry) in ts.iter_types().enumerate() { + let n = type_entry.describe(); + println!("{:>4} {}", idx, n); } + println!("-----------------------------------------------------"); + println!(); - a("}"); + let file = quote! { + use anyhow::Result; - Ok(out) + mod progenitor_support { + use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; + + #[allow(dead_code)] + const PATH_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + + #[allow(dead_code)] + pub(crate) fn encode_path(pc: &str) -> String { + utf8_percent_encode(pc, PATH_SET).to_string() + } + } + + pub mod types { + use serde::{Deserialize, Serialize}; + #(#types)* + } + + pub struct Client { + baseurl: String, + client: reqwest::Client, + } + + impl Client { + pub fn new(baseurl: &str) -> Client { + let dur = std::time::Duration::from_secs(15); + let client = reqwest::ClientBuilder::new() + .connect_timeout(dur) + .timeout(dur) + .build() + .unwrap(); + + Client::new_with_client(baseurl, client) + } + + pub fn new_with_client( + baseurl: &str, + client: reqwest::Client, + ) -> Client { + Client { + baseurl: baseurl.to_string(), + client, + } + } + + #(#methods)* + } + }; + let file = rustfmt_wrapper::rustfmt(file).unwrap(); + + Ok(file) } fn main() -> Result<()> { @@ -1483,101 +853,24 @@ fn main() -> Result<()> { let api = load_api(&args.opt_str("i").unwrap())?; - let mut ts = TypeSpace::new(); + // Convert our components dictionary to schemars + let schemas = api + .components + .iter() + .flat_map(|components| { + components.schemas.iter().map(|(name, ref_or_schema)| { + (name.clone(), ref_or_schema.to_schema()) + }) + }) + .collect::>(); - if let Some(components) = &api.components { - /* - * First, grant each expected reference a type ID. Each - * "components.schemas" entry needs an established reference for - * resolution in this and other parts of the document. - */ - for n in components.schemas.keys() { - println!("PREPOP {}:", n); - ts.prepop_reference(n, &format!("#/components/schemas/{}", n))?; - } - println!(); + // Create a new type space, prepopulated with our referenced schemas. + let mut ts = TypeSpace::default(); + ts.set_type_mod("types"); - /* - * Populate a type to describe each entry in the schemas section: - */ - for (i, (sn, s)) in components.schemas.iter().enumerate() { - println!("SCHEMA {}/{}: {}", i + 1, components.schemas.len(), sn); + ts.add_ref_types(schemas)?; - let id = ts.select(Some(sn.as_str()), s)?; - println!(" -> {:?}", id); - - println!(); - } - } - - /* - * In addition to types defined in schemas, types may be defined inline in - * request and response bodies. - */ - for (pn, p) in api.paths.paths.iter() { - let op = p.item()?; - - let grab = |pn: &str, - m: &str, - o: Option<&openapiv3::Operation>, - ts: &mut TypeSpace| - -> Result<()> { - if let Some(o) = o { - /* - * Get the request body type, if this operation has one: - */ - match &o.request_body { - Some(openapiv3::ReferenceOr::Item(body)) => { - if !body.is_binary()? { - let mt = - body.content_json().with_context(|| { - anyhow!("{} {} request", m, pn) - })?; - if let Some(s) = &mt.schema { - let id = ts.select(None, s)?; - println!( - " {} {} request body -> {:?}", - pn, m, id - ); - } - } - } - _ => {} - } - - /* - * Get the response body type for each status code: - */ - for (code, r) in o.responses.responses.iter() { - let ri = r.item()?; - if !ri.is_binary()? && !ri.content.is_empty() { - let mt = ri.content_json().with_context(|| { - anyhow!("{} {} {}", m, pn, code) - })?; - if let Some(s) = &mt.schema { - let id = ts.select(None, s)?; - println!( - " {} {} {} response body -> {:?}", - pn, m, code, id - ); - } - } - } - } - Ok(()) - }; - - grab(pn, "GET", op.get.as_ref(), &mut ts)?; - grab(pn, "POST", op.post.as_ref(), &mut ts)?; - grab(pn, "PUT", op.put.as_ref(), &mut ts)?; - grab(pn, "DELETE", op.delete.as_ref(), &mut ts)?; - grab(pn, "OPTIONS", op.options.as_ref(), &mut ts)?; - grab(pn, "HEAD", op.head.as_ref(), &mut ts)?; - grab(pn, "PATCH", op.patch.as_ref(), &mut ts)?; - grab(pn, "TRACE", op.trace.as_ref(), &mut ts)?; - } - - let fail = match gen(&api, &mut ts) { + let fail = match generate(&api, &mut ts) { Ok(out) => { let name = args.opt_str("n").unwrap(); let version = args.opt_str("v").unwrap(); @@ -1593,16 +886,21 @@ fn main() -> Result<()> { */ let mut toml = root.clone(); toml.push("Cargo.toml"); - let chrono = if ts.import_chrono { + let chrono = if ts.uses_chrono() { "chrono = { version = \"0.4\", features = [\"serde\"] }\n" } else { "" }; - let uuid = if ts.import_uuid { + let uuid = if ts.uses_uuid() { "uuid = { version = \"0.8\", features = [\"serde\", \"v4\"] }\n" } else { "" }; + let serde_json = if ts.uses_uuid() { + "serde_json = \"1\"\n" + } else { + "" + }; let tomlout = format!( "[package]\n\ name = \"{}\"\n\ @@ -1615,8 +913,9 @@ fn main() -> Result<()> { {}\ percent-encoding = \"2.1\"\n\ reqwest = {{ version = \"0.11\", features = [\"json\"] }}\n\ - serde = {{ version = \"1\", features = [\"derive\"] }}\n", - name, version, chrono, uuid, + serde = {{ version = \"1\", features = [\"derive\"] }}\n\ + {}", + name, version, chrono, uuid, serde_json, ); save(&toml, tomlout.as_str())?; @@ -1641,16 +940,6 @@ fn main() -> Result<()> { } }; - println!("-----------------------------------------------------"); - println!(" TYPE SPACE"); - println!("-----------------------------------------------------"); - for te in ts.id_to_entry.values() { - let n = ts.describe(&te.id); - println!("{:>4} {}", te.id.0, n); - } - println!("-----------------------------------------------------"); - println!(); - if fail { bail!("generation experienced errors"); } diff --git a/src/template.rs b/src/template.rs index 8d7ffab..5c64e02 100644 --- a/src/template.rs +++ b/src/template.rs @@ -1,4 +1,6 @@ use anyhow::{anyhow, bail, Context, Result}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; #[derive(Eq, PartialEq, Clone, Debug)] enum Component { @@ -12,28 +14,31 @@ pub struct Template { } impl Template { - pub fn compile(&self) -> String { - let mut out = " let url = format!(\"{}".to_string(); + pub fn compile(&self) -> TokenStream { + let mut fmt = String::new(); + fmt.push_str("{}"); for c in self.components.iter() { - out.push('/'); + fmt.push('/'); match c { - Component::Constant(n) => out.push_str(n), - Component::Parameter(_) => out.push_str("{}"), + Component::Constant(n) => fmt.push_str(n), + Component::Parameter(_) => fmt.push_str("{}"), } } - out.push_str("\",\n"); - out.push_str(" self.baseurl,\n"); - for c in self.components.iter() { - if let Component::Parameter(n) = &c { - out.push_str(&format!( - " \ - progenitor_support::encode_path(&{}.to_string()),\n", - n - )); + + let components = self.components.iter().filter_map(|component| { + if let Component::Parameter(n) = &component { + let param = format_ident!("{}", n); + Some(quote! { + progenitor_support::encode_path(&#param.to_string()) + }) + } else { + None } + }); + + quote! { + let url = format!(#fmt, self.baseurl, #(#components,)*); } - out.push_str(" );\n"); - out } pub fn names(&self) -> Vec { @@ -192,11 +197,13 @@ mod test { fn compile() -> Result<()> { let t = parse("/measure/{number}")?; let out = t.compile(); - let want = " let url = format!(\"{}/measure/{}\",\ - \n self.baseurl,\ - \n progenitor_support::encode_path(&number.to_string()),\ - \n );\n"; - assert_eq!(want, &out); + let want = quote::quote! { + let url = format!("{}/measure/{}", + self.baseurl, + progenitor_support::encode_path(&number.to_string()), + ); + }; + assert_eq!(want.to_string(), out.to_string()); Ok(()) } } diff --git a/src/to_schema.rs b/src/to_schema.rs new file mode 100644 index 0000000..6f2d48f --- /dev/null +++ b/src/to_schema.rs @@ -0,0 +1,523 @@ +use indexmap::IndexMap; +use openapiv3::AnySchema; +use serde_json::Value; + +pub trait ToSchema { + fn to_schema(&self) -> schemars::schema::Schema; +} + +trait Convert { + fn convert(&self) -> T; +} + +impl ToSchema for openapiv3::Schema { + fn to_schema(&self) -> schemars::schema::Schema { + self.convert() + } +} + +impl ToSchema for openapiv3::ReferenceOr { + fn to_schema(&self) -> schemars::schema::Schema { + self.convert() + } +} + +impl Convert> for Vec +where + I: Convert, +{ + fn convert(&self) -> Vec { + self.iter().map(Convert::convert).collect() + } +} + +impl Convert>> for Vec +where + I: Convert, +{ + fn convert(&self) -> Option> { + if self.is_empty() { + None + } else { + Some(self.iter().map(Convert::convert).collect()) + } + } +} + +impl Convert + for openapiv3::ReferenceOr +{ + fn convert(&self) -> schemars::schema::Schema { + match self { + openapiv3::ReferenceOr::Reference { reference } => { + schemars::schema::SchemaObject::new_ref(reference.clone()) + .into() + } + openapiv3::ReferenceOr::Item(schema) => schema.convert(), + } + } +} + +impl Convert for openapiv3::ReferenceOr> +where + openapiv3::ReferenceOr: Convert, + T: Clone, +{ + fn convert(&self) -> TT { + self.clone().unbox().convert() + } +} + +impl Convert for openapiv3::Schema { + fn convert(&self) -> schemars::schema::Schema { + // TODO the discriminator field is used in a way that seems both + // important and unfortunately redundant. It corresponds to the same + // regime as internally tagged enums in the serde sense: a field that + // the discriminator defines is used to determine which schema is + // valid. This can base used in two ways: + + // 1. It can be used within a struct to identify a particular, required + // field. This is typically done on a "base" class in an OOP hierarchy. + // Child class structs "extend" that base class by using an allOf + // construction where the parent is one of the subschemas. To recognize + // this case, we need to check all subschemas in an allOf to see if any + // of them have a discriminator. If they do, we can simply construct an + // additional structure for the allOf that has a fixed value for that + // field. + + // 2. It can be used within a oneOf or anyOf schema to determine which + // subschema is relevant. This is easier to detect because it doesn't + // required chasing references. For each subschema we can then make it + // an allOf union of the actual subschema along with a fixed-field + // structure. + + let openapiv3::SchemaData { + nullable, + discriminator: _, // TODO: see above + external_docs: _, // TODO: append to description? + + title, + description, + default, + deprecated, + read_only, + write_only, + example, + } = self.schema_data.clone(); + + let metadata = schemars::schema::Metadata { + id: None, + title, + description, + default, + deprecated, + read_only, + write_only, + examples: example.into_iter().collect::>(), + }; + + let metadata = Some(Box::new(metadata)).reduce(); + + match &self.schema_kind { + openapiv3::SchemaKind::Type(openapiv3::Type::String( + openapiv3::StringType { + format, + pattern, + enumeration, + min_length, + max_length, + }, + )) => schemars::schema::SchemaObject { + metadata, + instance_type: instance_type( + schemars::schema::InstanceType::String, + nullable, + ), + format: format.convert(), + enum_values: enumeration.convert(), + string: Some(Box::new(schemars::schema::StringValidation { + max_length: max_length.convert(), + min_length: min_length.convert(), + pattern: pattern.clone(), + })) + .reduce(), + ..Default::default() + }, + openapiv3::SchemaKind::Type(openapiv3::Type::Number( + openapiv3::NumberType { + format, + multiple_of, + exclusive_minimum, + exclusive_maximum, + minimum, + maximum, + enumeration, + }, + )) => { + let (maximum, exclusive_maximum) = + match (maximum, exclusive_maximum) { + (None, _) => (None, None), + (Some(v), false) => (Some(*v), None), + (Some(v), true) => (None, Some(*v)), + }; + let (minimum, exclusive_minimum) = + match (minimum, exclusive_minimum) { + (None, _) => (None, None), + (Some(v), false) => (Some(*v), None), + (Some(v), true) => (None, Some(*v)), + }; + schemars::schema::SchemaObject { + metadata, + instance_type: instance_type( + schemars::schema::InstanceType::Number, + nullable, + ), + format: format.convert(), + enum_values: enumeration.convert(), + number: Some(Box::new( + schemars::schema::NumberValidation { + multiple_of: multiple_of.convert(), + maximum, + exclusive_maximum, + minimum, + exclusive_minimum, + }, + )) + .reduce(), + ..Default::default() + } + } + + openapiv3::SchemaKind::Type(openapiv3::Type::Integer( + openapiv3::IntegerType { + format, + multiple_of, + exclusive_minimum, + exclusive_maximum, + minimum, + maximum, + enumeration, + }, + )) => { + let (maximum, exclusive_maximum) = + match (maximum, exclusive_maximum) { + (None, _) => (None, None), + (Some(v), false) => (Some(*v as f64), None), + (Some(v), true) => (None, Some(*v as f64)), + }; + let (minimum, exclusive_minimum) = + match (minimum, exclusive_minimum) { + (None, _) => (None, None), + (Some(v), false) => (Some(*v as f64), None), + (Some(v), true) => (None, Some(*v as f64)), + }; + schemars::schema::SchemaObject { + metadata, + instance_type: instance_type( + schemars::schema::InstanceType::Integer, + nullable, + ), + format: format.convert(), + enum_values: enumeration.convert(), + number: Some(Box::new( + schemars::schema::NumberValidation { + multiple_of: multiple_of + .map(|v| v as f64) + .convert(), + maximum, + exclusive_maximum, + minimum, + exclusive_minimum, + }, + )) + .reduce(), + ..Default::default() + } + } + + openapiv3::SchemaKind::Type(openapiv3::Type::Object( + openapiv3::ObjectType { + properties, + required, + additional_properties, + min_properties, + max_properties, + }, + )) => schemars::schema::SchemaObject { + metadata, + instance_type: instance_type( + schemars::schema::InstanceType::Object, + nullable, + ), + object: Some(Box::new(schemars::schema::ObjectValidation { + max_properties: max_properties.convert(), + min_properties: min_properties.convert(), + required: required.convert(), + properties: properties.convert(), + pattern_properties: schemars::Map::default(), + additional_properties: additional_properties.convert(), + property_names: None, + })) + .reduce(), + ..Default::default() + }, + + openapiv3::SchemaKind::Type(openapiv3::Type::Array( + openapiv3::ArrayType { + items, + min_items, + max_items, + unique_items, + }, + )) => schemars::schema::SchemaObject { + metadata, + instance_type: instance_type( + schemars::schema::InstanceType::Array, + nullable, + ), + array: Some(Box::new(schemars::schema::ArrayValidation { + items: items.as_ref().map(|items| { + schemars::schema::SingleOrVec::Single(Box::new( + items.convert(), + )) + }), + additional_items: None, + max_items: max_items.convert(), + min_items: min_items.convert(), + unique_items: if *unique_items { Some(true) } else { None }, + contains: None, + })) + .reduce(), + ..Default::default() + }, + + openapiv3::SchemaKind::Type(openapiv3::Type::Boolean {}) => { + schemars::schema::SchemaObject { + metadata, + instance_type: instance_type( + schemars::schema::InstanceType::Boolean, + nullable, + ), + ..Default::default() + } + } + + openapiv3::SchemaKind::OneOf { one_of } => { + schemars::schema::SchemaObject { + subschemas: Some(Box::new( + schemars::schema::SubschemaValidation { + one_of: Some(one_of.convert()), + ..Default::default() + }, + )), + ..Default::default() + } + } + + openapiv3::SchemaKind::AllOf { all_of } => { + schemars::schema::SchemaObject { + subschemas: Some(Box::new( + schemars::schema::SubschemaValidation { + all_of: Some(all_of.convert()), + ..Default::default() + }, + )), + ..Default::default() + } + } + + openapiv3::SchemaKind::AnyOf { any_of } => { + schemars::schema::SchemaObject { + subschemas: Some(Box::new( + schemars::schema::SubschemaValidation { + any_of: Some(any_of.convert()), + ..Default::default() + }, + )), + ..Default::default() + } + } + + // This is the permissive schema that allows anything to match. + openapiv3::SchemaKind::Any(AnySchema { + pattern: None, + multiple_of: None, + exclusive_minimum: None, + exclusive_maximum: None, + minimum: None, + maximum: None, + properties, + required, + additional_properties: None, + min_properties: None, + max_properties: None, + items: None, + min_items: None, + max_items: None, + unique_items: None, + format: None, + }) if properties.is_empty() && required.is_empty() => { + schemars::schema::Schema::Bool(true).into_object() + } + + openapiv3::SchemaKind::Any(_) => { + panic!("not clear what we could usefully do here {:#?}", self) + } + } + .into() + } +} + +impl Convert> for openapiv3::VariantOrUnknownOrEmpty +where + T: Convert, +{ + fn convert(&self) -> Option { + match self { + openapiv3::VariantOrUnknownOrEmpty::Item(i) => Some(i.convert()), + openapiv3::VariantOrUnknownOrEmpty::Unknown(s) => Some(s.clone()), + openapiv3::VariantOrUnknownOrEmpty::Empty => None, + } + } +} + +impl Convert for openapiv3::StringFormat { + fn convert(&self) -> String { + match self { + openapiv3::StringFormat::Date => "date", + openapiv3::StringFormat::DateTime => "date-time", + openapiv3::StringFormat::Password => "password", + openapiv3::StringFormat::Byte => "byte", + openapiv3::StringFormat::Binary => "binary", + } + .to_string() + } +} + +impl Convert for openapiv3::NumberFormat { + fn convert(&self) -> String { + match self { + openapiv3::NumberFormat::Float => "float", + openapiv3::NumberFormat::Double => "double", + } + .to_string() + } +} + +impl Convert for openapiv3::IntegerFormat { + fn convert(&self) -> String { + match self { + openapiv3::IntegerFormat::Int32 => "int32", + openapiv3::IntegerFormat::Int64 => "int64", + } + .to_string() + } +} + +impl Convert for Option { + fn convert(&self) -> Value { + match self { + Some(value) => Value::String(value.clone()), + None => Value::Null, + } + } +} + +impl Convert for f64 { + fn convert(&self) -> Value { + Value::Number(serde_json::Number::from_f64(*self).unwrap()) + } +} +impl Convert for i64 { + fn convert(&self) -> Value { + Value::Number(serde_json::Number::from(*self)) + } +} + +fn instance_type( + instance_type: schemars::schema::InstanceType, + nullable: bool, +) -> Option> { + if nullable { + Some(schemars::schema::SingleOrVec::Vec(vec![ + instance_type, + schemars::schema::InstanceType::Null, + ])) + } else { + Some(schemars::schema::SingleOrVec::Single(Box::new( + instance_type, + ))) + } +} + +impl Convert> for Option { + fn convert(&self) -> Option { + (*self).map(|m| m as u32) + } +} + +impl Convert> for Option { + fn convert(&self) -> Option { + *self + } +} + +impl Convert> for Vec { + fn convert(&self) -> schemars::Set { + self.iter().cloned().collect() + } +} + +impl Convert> + for IndexMap>> +{ + fn convert(&self) -> schemars::Map { + self.iter().map(|(k, v)| (k.clone(), v.convert())).collect() + } +} + +impl Convert for Box +where + T: Convert, +{ + fn convert(&self) -> TT { + self.as_ref().convert() + } +} + +impl Convert>> for Option +where + T: Convert, +{ + fn convert(&self) -> Option> { + self.as_ref().map(|t| Box::new(t.convert())) + } +} + +impl Convert for openapiv3::AdditionalProperties { + fn convert(&self) -> schemars::schema::Schema { + match self { + openapiv3::AdditionalProperties::Any(b) => { + schemars::schema::Schema::Bool(*b) + } + openapiv3::AdditionalProperties::Schema(schema) => schema.convert(), + } + } +} + +trait OptionReduce { + fn reduce(self) -> Self; +} + +// If an Option is `Some` of it's default value, we can simplify that to `None` +impl OptionReduce for Option +where + T: Default + PartialEq + std::fmt::Debug, +{ + fn reduce(self) -> Self { + match &self { + Some(s) if s != &T::default() => self, + _ => None, + } + } +}