chore: Initial commit.

This commit is contained in:
Lina Butler
2025-12-18 12:54:50 -08:00
commit 209aee0717
36 changed files with 5762 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
/target
Generated
+800
View File
@@ -0,0 +1,800 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "backtrace-ext"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
dependencies = [
"backtrace",
]
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "compact_str"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"backtrace",
"backtrace-ext",
"cfg-if",
"miette-derive",
"owo-colors",
"supports-color",
"supports-hyperlinks",
"supports-unicode",
"terminal_size",
"textwrap",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "owo-colors"
version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "ploidy"
version = "0.1.0"
dependencies = [
"clap",
"compact_str",
"heck",
"indexmap",
"indoc",
"itertools",
"miette",
"percent-encoding",
"petgraph",
"prettyplease",
"proc-macro2",
"quote",
"semver",
"serde",
"serde_json",
"serde_path_to_error",
"serde_yaml",
"syn",
"textwrap",
"thiserror",
"toml",
"unicase",
"unicode-ident",
"winnow",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-demangle"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"indexmap",
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]]
name = "supports-hyperlinks"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b"
[[package]]
name = "supports-unicode"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
[[package]]
name = "syn"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "terminal_size"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
dependencies = [
"rustix",
"windows-sys 0.60.2",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"unicode-linebreak",
"unicode-width 0.2.2",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "ploidy"
version = "0.1.0"
description = "An OpenAPI client generator that supports polymorphic schemas"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["cargo", "derive"] }
compact_str = "0.9"
heck = "0.5"
indexmap = { version = "2", features = ["serde"] }
indoc = "2"
itertools = "0.14"
miette = { version = "7", features = ["fancy"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_path_to_error = "0.1"
serde_yaml = "0.9.34"
percent-encoding = "2.3"
petgraph = "0.7"
prettyplease = "0.2"
proc-macro2 = { version = "1", default-features = false }
quote = { version = "1", default-features = false }
semver = { version = "1", features = ["serde"] }
syn = { version = "2", default-features = false, features = [
"parsing",
"printing",
] }
textwrap = { version = "0.16", default-features = false, features = [
"unicode-linebreak",
"unicode-width",
] }
thiserror = "2"
toml = { version = "0.9", default-features = false, features = [
"display",
"parse",
"preserve_order",
"serde",
] }
unicase = "2"
unicode-ident = "1"
winnow = "0.7"
[lib]
name = "ploidy"
path = "src/lib.rs"
+42
View File
@@ -0,0 +1,42 @@
# Ploidy
Ploidy is a code generator that supports polymorphic OpenAPI schemas. Its goal is to generate idiomatic Rust code for schemas with complex `oneOf`, `anyOf`, and `allOf` hierarchies.
## Installation
```sh
cargo install ploidy
```
## Quick Start
```sh
ploidy codegen <INPUT-SPEC> <OUTPUT-DIR> rust
```
This generates a complete Rust crate in the output directory, with:
* A `types` module, with Rust definitions for each named schema type.
* A `client` module, with methods for each operation.
### Options
| Flag | Description |
|------|-------------|
| `-c`, `--check` | Run `cargo check` on the generated code |
| `--package-name <NAME>` | Override the generated package name |
| `--package-version <VERSION>` | Override the generated package version |
| `--package-license <LICENSE>` | Override the generated package license |
| `--package-description <DESCRIPTION>` | Override the generated package description |
### Configuration File
In addition to the command-line options above, you can place a `.ploidy.toml` file in the output directory to configure generation:
```toml
[rust.package]
name = "my-api-client"
version = "1.0.0"
license = "MIT"
description = "A generated OpenAPI client for my API"
```
+70
View File
@@ -0,0 +1,70 @@
use std::path::Path;
use miette::{Context, IntoDiagnostic};
use proc_macro2::TokenStream;
use quote::ToTokens;
pub mod rust;
mod unique;
pub use unique::{UniqueNameSpace, WordSegments};
pub fn write_to_disk(output: &Path, code: impl IntoCode) -> miette::Result<()> {
let code = code.into_code();
let path = output.join(code.path());
let string = code.into_string()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.into_diagnostic()
.with_context(|| format!("Failed to create directory `{}`", parent.display()))?;
}
std::fs::write(&path, string)
.into_diagnostic()
.with_context(|| format!("Failed to write `{}`", path.display()))?;
Ok(())
}
pub trait Code {
fn path(&self) -> &str;
fn into_string(self) -> miette::Result<String>;
}
impl<T: AsRef<str>> Code for (T, TokenStream) {
fn path(&self) -> &str {
self.0.as_ref()
}
fn into_string(self) -> miette::Result<String> {
let file = syn::parse2(self.1.into_token_stream())
.into_diagnostic()
.with_context(|| format!("Failed to format `{}`", self.0.as_ref()))?;
Ok(prettyplease::unparse(&file))
}
}
impl Code for (&'static str, toml::map::Map<String, toml::Value>) {
fn path(&self) -> &str {
self.0
}
fn into_string(self) -> miette::Result<String> {
toml::to_string_pretty(&self.1)
.into_diagnostic()
.with_context(|| format!("Failed to serialize `{}`", self.0))
}
}
pub trait IntoCode {
type Code: Code;
fn into_code(self) -> Self::Code;
}
impl<T: Code> IntoCode for T {
type Code = T;
fn into_code(self) -> Self::Code {
self
}
}
+86
View File
@@ -0,0 +1,86 @@
use std::collections::BTreeSet;
use itertools::Itertools;
use toml::Value as TomlValue;
use crate::codegen::IntoCode;
use super::context::CodegenContext;
type TomlMap = toml::map::Map<String, TomlValue>;
#[derive(Clone, Debug)]
pub struct CargoManifest<'a> {
context: &'a CodegenContext<'a>,
}
impl<'a> CargoManifest<'a> {
#[inline]
pub fn new(context: &'a CodegenContext<'a>) -> Self {
Self { context }
}
pub fn into_map(self) -> TomlMap {
let name = self.context.name;
let version = self.context.version.to_string();
let license = self.context.license;
let mut package = toml::toml! {
name = name
version = version
edition = "2024"
license = license
};
if let Some(description) = self.context.description {
package.insert("description".into(), description.into());
}
let features = {
let names: BTreeSet<_> = self
.context
.spec
.operations()
.map(|view| view.op().resource)
.filter(|&name| name != "full")
.collect();
let mut features = names
.iter()
.map(|&name| (name.to_owned(), TomlValue::Array(vec![])))
.collect::<TomlMap>();
features.insert(
"full".to_owned(),
names
.iter()
.map(|&name| name.to_owned())
.collect_vec()
.into(),
);
features.insert("default".to_owned(), TomlValue::Array(vec![]));
features
};
toml::toml! {
package = package
features = features
[dependencies]
bytes = { version = "1", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
http = "1"
reqwest = { version = "0.12", default-features = false, features = ["http2", "json", "multipart", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_path_to_error = "0.1"
thiserror = "2"
url = { version = "2.5", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] }
}
}
}
impl IntoCode for CargoManifest<'_> {
type Code = (&'static str, TomlMap);
fn into_code(self) -> Self::Code {
("Cargo.toml", self.into_map())
}
}
+138
View File
@@ -0,0 +1,138 @@
use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::codegen::{IntoCode, rust::CodegenIdent};
use super::context::CodegenContext;
/// Generates the `client/mod.rs` source file.
#[derive(Clone, Copy, Debug)]
pub struct CodegenClientModule<'a> {
context: &'a CodegenContext<'a>,
resources: &'a [&'a str],
}
impl<'a> CodegenClientModule<'a> {
pub fn new(context: &'a CodegenContext<'a>, resources: &'a [&'a str]) -> Self {
Self { context, resources }
}
}
impl ToTokens for CodegenClientModule<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let mods = self
.resources
.iter()
.map(|resource| {
let mod_name = CodegenIdent::Module(resource);
quote! {
#[cfg(feature = #resource)]
pub mod #mod_name;
}
})
.collect_vec();
let client_doc = {
let info = self.context.spec.info();
format!("API client for {} (version {})", info.title, info.version)
};
tokens.append_all(quote! {
#[doc = #client_doc]
#[derive(Clone, Debug)]
pub struct Client {
client: ::reqwest::Client,
headers: ::http::HeaderMap,
base_url: ::url::Url,
}
impl Client {
/// Create a new client.
pub fn new(base_url: impl AsRef<str>) -> Result<Self, crate::error::Error> {
Ok(Self::with_reqwest_client(
::reqwest::Client::new(),
base_url.as_ref().parse()?,
))
}
pub fn with_reqwest_client(client: ::reqwest::Client, base_url: ::url::Url) -> Self {
Self {
client,
headers: ::http::HeaderMap::new(),
base_url,
}
}
/// Adds a header to each request.
pub fn with_header<K, V>(mut self, name: K, value: V) -> Result<Self, crate::error::Error>
where
K: TryInto<::http::HeaderName>,
V: TryInto<::http::HeaderValue>,
K::Error: Into<::http::Error>,
V::Error: Into<::http::Error>,
{
let name = name
.try_into()
.map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
let value = value
.try_into()
.map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
self.headers.insert(name, value);
Ok(Self {
client: self.client,
headers: self.headers,
base_url: self.base_url,
})
}
/// Adds a sensitive header to each request, like a password or a bearer token.
/// Sensitive headers won't appear in `Debug` output, and may be treated specially
/// by the underlying HTTP stack.
///
/// # Example
///
/// ```rust,ignore
/// use reqwest::header::AUTHORIZATION;
///
/// let client = Client::new("https://api.example.com")?
/// .with_sensitive_header(AUTHORIZATION, "Bearer decafbadcafed00d")?;
/// ```
pub fn with_sensitive_header<K, V>(self, name: K, value: V) -> Result<Self, crate::error::Error>
where
K: TryInto<::http::HeaderName>,
V: TryInto<::http::HeaderValue>,
K::Error: Into<::http::Error>,
V::Error: Into<::http::Error>,
{
let name = name
.try_into()
.map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
let mut value: ::http::HeaderValue = value
.try_into()
.map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
value.set_sensitive(true);
self.with_header(name, value)
}
pub fn with_user_agent<V>(self, value: V) -> Result<Self, crate::error::Error>
where
V: TryInto<::http::HeaderValue>,
V::Error: Into<::http::Error>,
{
self.with_header(::http::header::USER_AGENT, value)
}
}
#(#mods)*
});
}
}
impl IntoCode for CodegenClientModule<'_> {
type Code = (&'static str, TokenStream);
fn into_code(self) -> Self::Code {
("src/client/mod.rs", self.into_token_stream())
}
}
+50
View File
@@ -0,0 +1,50 @@
use semver::Version;
use crate::ir::{InnerLeaf, InnerRef, IrSpec, IrType, PrimitiveIrType};
use super::SchemaIdentMap;
#[derive(Debug)]
pub struct CodegenContext<'a> {
pub name: &'a str,
pub version: Version,
pub license: &'a str,
pub description: Option<&'a str>,
pub spec: &'a IrSpec<'a>,
pub map: SchemaIdentMap<'a>,
}
impl<'a> CodegenContext<'a> {
pub fn new(
name: &'a str,
version: Version,
license: &'a str,
description: Option<&'a str>,
spec: &'a IrSpec<'a>,
) -> Self {
Self {
name,
version,
description,
license,
spec,
map: SchemaIdentMap::new(spec),
}
}
pub fn hashable(&self, ty: &IrType<'_>) -> bool {
itertools::chain!(
ty.visit::<InnerLeaf>(),
ty.visit::<InnerRef>()
.flat_map(|r| self.spec.lookup(r.name()))
.flat_map(|view| view.refs())
.flat_map(|view| view.ty().visit::<InnerLeaf>()),
)
.all(|leaf| {
!matches!(
leaf,
InnerLeaf::Primitive(PrimitiveIrType::F32 | PrimitiveIrType::F64)
)
})
}
}
+33
View File
@@ -0,0 +1,33 @@
use proc_macro2::TokenStream;
use quote::ToTokens;
use syn::parse_quote;
/// Extra derives that can be added to types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExtraDerive {
/// Derive [`Eq`].
///
/// Excluded if the type is unhashable.
Eq,
/// Derive [`Hash`][std::hash::Hash].
///
/// Excluded if the type is unhashable.
Hash,
/// Derive [`Default`].
///
/// Included if all fields are optional.
Default,
}
impl ToTokens for ExtraDerive {
fn to_tokens(&self, tokens: &mut TokenStream) {
let path: syn::Path = match self {
Self::Eq => parse_quote!(Eq),
Self::Hash => parse_quote!(Hash),
Self::Default => parse_quote!(Default),
};
path.to_tokens(tokens);
}
}
+127
View File
@@ -0,0 +1,127 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, format_ident, quote};
use syn::{Ident, parse_quote};
use crate::ir::{IrEnum, IrEnumVariant};
use super::{
doc_attrs,
naming::{CodegenIdent, CodegenTypeName},
};
#[derive(Clone, Copy, Debug)]
pub struct CodegenEnum<'a> {
name: CodegenTypeName<'a>,
ty: &'a IrEnum<'a>,
}
impl<'a> CodegenEnum<'a> {
pub fn new(name: CodegenTypeName<'a>, ty: &'a IrEnum<'a>) -> Self {
Self { name, ty }
}
}
impl ToTokens for CodegenEnum<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let mut variants = Vec::new();
let mut display_arms = Vec::new();
let mut from_str_arms = Vec::new();
for variant in &self.ty.variants {
match variant {
IrEnumVariant::String(json_value) => {
let variant_name = CodegenIdent::Variant(json_value);
variants.push(quote! { #variant_name });
display_arms.push(quote! { Self::#variant_name => #json_value });
from_str_arms.push(quote! { #json_value => Self::#variant_name });
}
}
}
// The catch-all `Other` variant comes last.
let type_name: Ident = {
let name = self.name;
parse_quote!(#name)
};
let other_name = format_ident!("Other{}", type_name);
variants.push(quote! {
#[default]
#other_name
});
display_arms.push(quote! { Self::#other_name => "(other)" });
from_str_arms.push(quote! { _ => Self::#other_name });
let other_serialize_error = format!("can't serialize variant `{type_name}::{other_name}`");
let expecting = format!("a variant of `{type_name}`");
let doc_attrs = self.ty.description.map(doc_attrs);
tokens.append_all(quote! {
#doc_attrs
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum #type_name {
#(#variants),*
}
impl #type_name {
pub fn is_other(&self) -> bool {
matches!(self, Self::#other_name)
}
}
impl ::std::fmt::Display for #type_name {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
f.write_str(match self {
#(#display_arms),*
})
}
}
impl ::std::str::FromStr for #type_name {
type Err = ::std::convert::Infallible;
fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
::std::result::Result::Ok(match s {
#(#from_str_arms),*
})
}
}
impl<'de> ::serde::Deserialize<'de> for #type_name {
fn deserialize<D: ::serde::Deserializer<'de>>(
deserializer: D,
) -> ::std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> ::serde::de::Visitor<'de> for Visitor {
type Value = #type_name;
fn expecting(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
f.write_str(#expecting)
}
fn visit_str<E: ::serde::de::Error>(
self,
s: &str,
) -> ::std::result::Result<Self::Value, E> {
let ::std::result::Result::Ok(v) = ::std::str::FromStr::from_str(s);
Ok(v)
}
}
::serde::Deserializer::deserialize_str(deserializer, Visitor)
}
}
impl ::serde::Serialize for #type_name {
fn serialize<S: ::serde::Serializer>(
&self,
serializer: S,
) -> ::std::result::Result<S::Ok, S::Error> {
match self {
Self::#other_name => Err(::serde::ser::Error::custom(#other_serialize_error)),
v => v.to_string().serialize(serializer),
}
}
}
})
}
}
+156
View File
@@ -0,0 +1,156 @@
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
use indexmap::IndexMap;
use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Ident, parse_quote};
use crate::{
codegen::{IntoCode, unique::UniqueNameSpace, write_to_disk},
ir::{IrOperationView, IrSpec, IrType},
};
mod cargo;
mod client;
mod context;
mod derives;
mod enum_;
mod naming;
mod operation;
mod ref_;
mod resource;
mod schema;
mod statics;
mod struct_;
mod tagged;
mod types;
mod untagged;
pub use cargo::*;
pub use client::*;
pub use context::*;
pub use naming::*;
pub use operation::*;
pub use resource::*;
pub use schema::*;
pub use statics::*;
pub use types::*;
pub fn write_types_to_disk(output: &Path, context: &CodegenContext<'_>) -> miette::Result<()> {
let mut resources_by_type = BTreeMap::<&str, BTreeSet<&str>>::new();
for view in context.spec.operations() {
let resource = view.op().resource;
for v in view.refs() {
resources_by_type
.entry(v.name())
.or_default()
.insert(resource);
}
}
for view in context.spec.schemas() {
let name = view.name();
let ty = view.ty();
let Some(info) = context.map.0.get(name) else {
continue;
};
if !resources_by_type.contains_key(name) {
continue;
}
let name = CodegenTypeName::Schema(name, &info.ty);
let code = match ty {
IrType::Schema(ty) => CodegenSchemaType::new(context, name, ty).into_code(),
IrType::Nullable(ty) | IrType::Array(ty) | IrType::Map(ty) => {
CodegenSchemaTypeAlias::new(context, name, ty.as_ref()).into_code()
}
IrType::Any => CodegenSchemaTypeAlias::new(context, name, &IrType::Any).into_code(),
IrType::Primitive(_) | IrType::Inline(..) | IrType::Ref(..) => continue,
};
write_to_disk(output, code)?;
}
write_to_disk(output, CodegenTypesModule::new(context))?;
Ok(())
}
pub fn write_client_to_disk(output: &Path, context: &CodegenContext<'_>) -> miette::Result<()> {
let mut operations_by_resource: BTreeMap<&str, Vec<IrOperationView<'_>>> = BTreeMap::new();
for view in context.spec.operations() {
let resource = view.op().resource;
operations_by_resource
.entry(resource)
.or_default()
.push(view);
}
for (resource, operations) in &operations_by_resource {
let code = CodegenResource::new(context, resource, operations.as_slice());
write_to_disk(output, code)?;
}
let resources = operations_by_resource.keys().cloned().collect_vec();
let mod_code = CodegenClientModule::new(context, &resources);
write_to_disk(output, mod_code)?;
Ok(())
}
/// Generates one or more `#[doc]` attributes for a schema description,
/// wrapping at 80 characters for readability.
pub fn doc_attrs(description: &str) -> TokenStream {
let lines = textwrap::wrap(description, 80)
.into_iter()
.map(|line| quote!(#[doc = #line]));
quote! { #(#lines)* }
}
#[derive(Debug)]
pub struct SchemaIdents {
pub module: Ident,
pub ty: Ident,
}
/// A mapping of schema names from the original spec
/// to Rust identifiers for the generated module and type names.
#[derive(Debug)]
pub struct SchemaIdentMap<'a>(pub IndexMap<&'a str, SchemaIdents>);
impl<'a> SchemaIdentMap<'a> {
pub fn new(spec: &'a IrSpec<'a>) -> Self {
let mut space = UniqueNameSpace::new();
let map = spec
.schemas()
.map(|view| {
let name = view.name();
let unique_name = space.uniquify(name);
let module = CodegenIdent::Module(&unique_name);
let ty = CodegenIdent::Type(&unique_name);
(
name,
SchemaIdents {
module: parse_quote!(#module),
ty: parse_quote!(#ty),
},
)
})
.collect();
Self(map)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &SchemaIdents)> {
self.0.iter().map(|(&key, idents)| (key, idents))
}
pub fn module(&self, name: &str) -> Option<&Ident> {
self.0.get(name).map(|idents| &idents.module)
}
pub fn ty(&self, name: &str) -> Option<&Ident> {
self.0.get(name).map(|idents| &idents.ty)
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::borrow::Cow;
use heck::{ToPascalCase, ToSnakeCase};
use proc_macro2::{Ident, Span, TokenStream};
use quote::{IdentFragment, ToTokens, TokenStreamExt, format_ident};
use crate::ir::{
InlineIrTypePath, InlineIrTypePathSegment, IrUntaggedVariantNameHint, PrimitiveIrType,
};
#[derive(Clone, Copy, Debug)]
pub enum CodegenTypeName<'a> {
Schema(&'a str, &'a Ident),
Inline(&'a InlineIrTypePath<'a>),
}
impl ToTokens for CodegenTypeName<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
match self {
&Self::Schema(_, ident) => tokens.append(ident.clone()),
Self::Inline(path) => {
let ident = path
.segments
.iter()
.map(CodegenTypePathSegment)
.fold(None, |ident, segment| {
Some(match ident {
Some(ident) => format_ident!("{}{}", ident, segment),
None => format_ident!("{}", segment),
})
})
.ok_or_else(|| syn::Error::new(Span::call_site(), "empty inline type path"));
match ident {
Ok(ident) => tokens.append(ident),
Err(err) => tokens.append_all(err.into_compile_error()),
}
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum CodegenIdent<'a> {
Module(&'a str),
Type(&'a str),
Field(&'a str),
Variant(&'a str),
Param(&'a str),
Method(&'a str),
}
impl<'a> CodegenIdent<'a> {
fn name(&self) -> &'a str {
let (Self::Module(s)
| Self::Type(s)
| Self::Field(s)
| Self::Variant(s)
| Self::Param(s)
| Self::Method(s)) = self;
s
}
}
impl ToTokens for CodegenIdent<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let cased = match self {
Self::Module(name) | Self::Field(name) | Self::Param(name) | Self::Method(name) => {
name.to_snake_case()
}
Self::Type(name) | Self::Variant(name) => name.to_pascal_case(),
};
let cleaned = clean(&cased);
let ident: syn::Result<Ident> = syn::parse_str(&cleaned)
.or_else(|_| syn::parse_str(&format!("r#{cleaned}")))
.or_else(|_| syn::parse_str(&format!("{cleaned}_")))
.map_err(|_| {
syn::Error::new(
Span::call_site(),
format!(
"`{}` can't be represented as a Rust identifier",
self.name()
),
)
});
match ident {
Ok(ident) => tokens.append(ident),
Err(err) => tokens.append_all(err.into_compile_error()),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct CodegenUntaggedVariantName(pub IrUntaggedVariantNameHint);
impl ToTokens for CodegenUntaggedVariantName {
fn to_tokens(&self, tokens: &mut TokenStream) {
use IrUntaggedVariantNameHint::*;
let s = match self.0 {
Primitive(PrimitiveIrType::String) => "String".into(),
Primitive(PrimitiveIrType::I32) => "I32".into(),
Primitive(PrimitiveIrType::I64) => "I64".into(),
Primitive(PrimitiveIrType::F32) => "F32".into(),
Primitive(PrimitiveIrType::F64) => "F64".into(),
Primitive(PrimitiveIrType::Bool) => "Bool".into(),
Primitive(PrimitiveIrType::DateTime) => "DateTime".into(),
Primitive(PrimitiveIrType::Date) => "Date".into(),
Primitive(PrimitiveIrType::Url) => "Url".into(),
Primitive(PrimitiveIrType::Uuid) => "Uuid".into(),
Primitive(PrimitiveIrType::Bytes) => "Bytes".into(),
Array => "Array".into(),
Map => "Map".into(),
Index(index) => Cow::Owned(format!("V{index}")),
};
tokens.append(Ident::new(&s, Span::call_site()));
}
}
#[derive(Clone, Copy, Debug)]
pub struct CodegenTypePathSegment<'a>(&'a InlineIrTypePathSegment<'a>);
impl IdentFragment for CodegenTypePathSegment<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.0 {
InlineIrTypePathSegment::Operation(name) => f.write_str(&name.to_pascal_case()),
InlineIrTypePathSegment::Parameter(name) => f.write_str(&name.to_pascal_case()),
InlineIrTypePathSegment::Request => f.write_str("Request"),
InlineIrTypePathSegment::Response => f.write_str("Response"),
InlineIrTypePathSegment::Field(name) => f.write_str(&name.to_pascal_case()),
InlineIrTypePathSegment::MapValue => f.write_str("Value"),
InlineIrTypePathSegment::ArrayItem => f.write_str("Item"),
InlineIrTypePathSegment::Variant(index) => write!(f, "V{index}"),
}
}
}
pub fn clean(s: &str) -> String {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut string = String::with_capacity(s.len());
if first == '_' || unicode_ident::is_xid_start(first) {
string.push(first);
} else {
string.push('_');
chars = s.chars();
}
string.push_str(
&chars
.as_str()
.replace(|next| !unicode_ident::is_xid_continue(next), "_"),
);
string
}
+272
View File
@@ -0,0 +1,272 @@
use itertools::Itertools;
use proc_macro2::{Span, TokenStream};
use quote::{ToTokens, TokenStreamExt, quote};
use syn::Ident;
use crate::{
codegen::rust::doc_attrs,
ir::{IrOperation, IrParameter, IrParameterInfo, IrRequest, IrResponse, IrType},
parse::{Method, path::PathFragment},
};
use super::{context::CodegenContext, naming::CodegenIdent, ref_::CodegenRef};
/// Generates a single client method for an API operation.
pub struct CodegenOperation<'a> {
context: &'a CodegenContext<'a>,
op: &'a IrOperation<'a>,
}
impl<'a> CodegenOperation<'a> {
pub fn new(context: &'a CodegenContext<'a>, op: &'a IrOperation<'a>) -> Self {
Self { context, op }
}
/// Generates code to build the request URL, with path parameters substituted.
fn url(&self, params: &[&IrParameterInfo<'_>]) -> TokenStream {
let segments = &self.op.path;
let segs = segments.iter().map(|segment| match segment.fragments() {
[] => quote! { "" },
[PathFragment::Literal(text)] => quote! { #text },
[PathFragment::Param(name)] => {
let info = params.iter().find(|param| param.name == *name).unwrap();
let value = CodegenIdent::Param(info.name);
quote! { #value }
}
fragments => {
// Build a format string, with placeholders for parameter fragments.
let format = fragments.iter().fold(String::new(), |mut f, fragment| {
match fragment {
PathFragment::Literal(text) => {
f.push_str(&text.replace('{', "{{").replace('}', "}}"))
}
PathFragment::Param(_) => f.push_str("{}"),
}
f
});
let args = fragments
.iter()
.filter_map(|fragment| match fragment {
PathFragment::Param(name) => Some(name),
PathFragment::Literal(_) => None,
})
.map(|name| {
// `url::PathSegmentsMut::push` percent-encodes the
// full segment, so we can interpolate fragments
// directly.
let info = params.iter().find(|param| param.name == *name).unwrap();
CodegenIdent::Param(info.name)
});
quote! { &format!(#format, #(#args),*) }
}
});
quote! {
let url = {
let mut url = self.base_url.clone();
url
.path_segments_mut()
.map_err(|()| crate::error::Error::UrlCannotBeABase)?
.pop_if_empty()
#(.push(#segs))*;
url
};
}
}
/// Generates code to append query parameters.
fn query(&self, params: &[&IrParameterInfo<'_>]) -> TokenStream {
if params.is_empty() {
return quote! {};
}
let mut param_serializations = Vec::new();
for param in params {
let param_name_str = &param.name;
let param_ident = CodegenIdent::Param(param.name);
let serialization = match (&param.ty, param.required) {
(IrType::Array(_), true) => {
quote! {
for value in &#param_ident {
url.query_pairs_mut()
.append_pair(#param_name_str, &value.to_string());
}
}
}
(IrType::Array(_), false) => {
quote! {
if let Some(ref values) = #param_ident {
for value in values {
url.query_pairs_mut()
.append_pair(#param_name_str, &value.to_string());
}
}
}
}
(_, true) => {
quote! {
url.query_pairs_mut()
.append_pair(#param_name_str, &#param_ident.to_string());
}
}
(_, false) => {
quote! {
if let Some(ref value) = #param_ident {
url.query_pairs_mut()
.append_pair(#param_name_str, &value.to_string());
}
}
}
};
param_serializations.push(serialization);
}
quote! {
let url = {
let mut url = url;
#(#param_serializations)*
url
};
}
}
}
impl ToTokens for CodegenOperation<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let operation_id = self.op.id;
let method_name = CodegenIdent::Method(operation_id);
let mut params = Vec::new();
let path_params = self
.op
.params
.iter()
.filter_map(|param| match param {
IrParameter::Path(info) => Some(info),
_ => None,
})
.collect_vec();
for param in &path_params {
let param_name = CodegenIdent::Param(param.name);
params.push(quote! { #param_name: &str });
}
let query_params = self
.op
.params
.iter()
.filter_map(|param| match param {
IrParameter::Query(info) => Some(info),
_ => None,
})
.collect_vec();
for param in &query_params {
let param_name = CodegenIdent::Param(param.name);
let base_type = CodegenRef::new(self.context, &param.ty);
let param_type = if param.required || matches!(param.ty, IrType::Nullable(_)) {
quote!(#base_type)
} else {
quote! { ::std::option::Option<#base_type> }
};
params.push(quote! { #param_name: #param_type });
}
if let Some(body_info) = &self.op.request {
match body_info {
IrRequest::Json(ty) => {
let param_type = CodegenRef::new(self.context, ty);
params.push(quote! { request: impl Into<#param_type> });
}
IrRequest::Multipart => {
params.push(quote! { form: reqwest::multipart::Form });
}
}
}
let return_type = match &self.op.response {
Some(IrResponse::Json(ty)) => CodegenRef::new(self.context, ty).into_token_stream(),
None => quote! { () },
};
let build_url = self.url(&path_params);
let build_query = self.query(&query_params);
let http_method = CodegenMethod(self.op.method);
let build_request = match &self.op.request {
Some(IrRequest::Json(_)) => quote! {
let response = self.client
.#http_method(url)
.headers(self.headers.clone())
.json(&request.into())
.send()
.await?
.error_for_status()?;
},
Some(IrRequest::Multipart) => quote! {
let response = self.client
.#http_method(url)
.headers(self.headers.clone())
.multipart(form)
.send()
.await?
.error_for_status()?;
},
None => quote! {
let response = self.client
.#http_method(url)
.headers(self.headers.clone())
.send()
.await?
.error_for_status()?;
},
};
let parse_response = if self.op.response.is_some() {
quote! {
let body = response.bytes().await?;
let deserializer = &mut serde_json::Deserializer::from_slice(&body);
let result = serde_path_to_error::deserialize(deserializer)
.map_err(crate::error::JsonError::from)?;
Ok(result)
}
} else {
quote! {
let _ = response;
Ok(())
}
};
let doc = self.op.description.map(doc_attrs);
tokens.append_all(quote! {
#doc
pub async fn #method_name(
&self,
#(#params),*
) -> Result<#return_type, crate::error::Error> {
#build_url
#build_query
#build_request
#parse_response
}
});
}
}
#[derive(Clone, Copy, Debug)]
pub struct CodegenMethod(pub Method);
impl ToTokens for CodegenMethod {
fn to_tokens(&self, tokens: &mut TokenStream) {
tokens.append(match self.0 {
Method::Get => Ident::new("get", Span::call_site()),
Method::Post => Ident::new("post", Span::call_site()),
Method::Put => Ident::new("put", Span::call_site()),
Method::Patch => Ident::new("patch", Span::call_site()),
Method::Delete => Ident::new("delete", Span::call_site()),
});
}
}
+109
View File
@@ -0,0 +1,109 @@
use heck::ToSnakeCase;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, format_ident, quote};
use syn::parse_quote;
use crate::{
codegen::rust::CodegenIdent,
ir::{InlineIrTypePathRoot, IrType, PrimitiveIrType},
};
use super::{context::CodegenContext, naming::CodegenTypeName};
#[derive(Clone, Copy, Debug)]
pub struct CodegenRef<'a> {
context: &'a CodegenContext<'a>,
ty: &'a IrType<'a>,
}
impl<'a> CodegenRef<'a> {
pub fn new(context: &'a CodegenContext<'a>, ty: &'a IrType<'a>) -> Self {
Self { context, ty }
}
}
impl ToTokens for CodegenRef<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
tokens.append_all(match self.ty {
&IrType::Primitive(PrimitiveIrType::String) => quote! { ::std::string::String },
&IrType::Primitive(PrimitiveIrType::I32) => quote! { i32 },
&IrType::Primitive(PrimitiveIrType::I64) => quote! { i64 },
&IrType::Primitive(PrimitiveIrType::F32) => quote! { f32 },
&IrType::Primitive(PrimitiveIrType::F64) => quote! { f64 },
&IrType::Primitive(PrimitiveIrType::Bool) => quote! { bool },
&IrType::Primitive(PrimitiveIrType::DateTime) => {
quote! { crate::date_time::UnixMilliseconds }
}
&IrType::Primitive(PrimitiveIrType::Date) => quote! { ::chrono::NaiveDate },
&IrType::Primitive(PrimitiveIrType::Url) => quote! { ::url::Url },
&IrType::Primitive(PrimitiveIrType::Uuid) => quote! { ::uuid::Uuid },
&IrType::Primitive(PrimitiveIrType::Bytes) => quote! { ::bytes::Bytes },
IrType::Array(ty) => {
let ty = CodegenRef::new(self.context, ty.as_ref());
quote! { ::std::vec::Vec<#ty> }
}
IrType::Map(ty) => {
let ty = CodegenRef::new(self.context, ty.as_ref());
quote! { ::std::collections::BTreeMap<::std::string::String, #ty> }
}
IrType::Ref(name) => {
let name = self.context.map.ty(name);
quote! { crate::types::#name }
}
IrType::Nullable(ty) => {
let ty = CodegenRef::new(self.context, ty.as_ref());
quote! { ::std::option::Option<#ty> }
}
IrType::Any => quote! { ::serde_json::Value },
IrType::Inline(ty) => {
let path = ty.path();
let root: syn::Path = match &path.root {
InlineIrTypePathRoot::Resource(a) => {
let name = format_ident!("{}", a.to_snake_case());
parse_quote!(crate::client::#name::types)
}
InlineIrTypePathRoot::Type(a) => {
let m = CodegenIdent::Module(a);
parse_quote!(crate::types::#m::fields)
}
};
let name = CodegenTypeName::Inline(path);
parse_quote!(#root::#name)
}
IrType::Schema(s) => {
let name = self.context.map.ty(s.name());
quote! { crate::types::#name }
}
})
}
}
/// A reference from one type to another type that may require boxing.
#[derive(Clone, Copy, Debug)]
pub struct CodegenBoxedRef<'a> {
context: &'a CodegenContext<'a>,
from: CodegenTypeName<'a>,
to: &'a IrType<'a>,
}
impl<'a> CodegenBoxedRef<'a> {
pub fn new(context: &'a CodegenContext, from: CodegenTypeName<'a>, to: &'a IrType<'a>) -> Self {
Self { context, from, to }
}
}
impl ToTokens for CodegenBoxedRef<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
if let CodegenTypeName::Schema(from, _) = &self.from
&& let Some(this) = self.context.spec.lookup(from)
&& let IrType::Ref(to) = &self.to
&& let Some(other) = self.context.spec.lookup(to)
&& this.requires_indirection_to(other)
{
let inner = CodegenRef::new(self.context, other.ty());
tokens.append_all(quote! { ::std::boxed::Box<#inner> });
} else {
CodegenRef::new(self.context, self.to).to_tokens(tokens);
}
}
}
+115
View File
@@ -0,0 +1,115 @@
use heck::ToSnakeCase;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::codegen::IntoCode;
use crate::ir::{InlineIrType, IrOperationView, IrParameter, IrRequest, IrResponse};
use super::{
context::CodegenContext, enum_::CodegenEnum, naming::CodegenTypeName,
operation::CodegenOperation, struct_::CodegenStruct, untagged::CodegenUntagged,
};
/// Generates a feature-gated `impl Client` block for a resource,
/// with all its operations.
pub struct CodegenResource<'a> {
context: &'a CodegenContext<'a>,
resource: &'a str,
operations: &'a [IrOperationView<'a>],
}
impl<'a> CodegenResource<'a> {
pub fn new(
context: &'a CodegenContext<'a>,
resource: &'a str,
operations: &'a [IrOperationView<'a>],
) -> Self {
Self {
context,
resource,
operations,
}
}
}
impl ToTokens for CodegenResource<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let feature_name = self.resource;
let methods: Vec<TokenStream> = self
.operations
.iter()
.map(|view| CodegenOperation::new(self.context, view.op()).into_token_stream())
.collect();
let mut inlines = self
.operations
.iter()
.flat_map(|op| {
itertools::chain!(
op.op()
.params
.iter()
.filter_map(|param| match param {
IrParameter::Path(_) => None,
IrParameter::Query(info) => Some(info),
})
.flat_map(|info| info.ty.visit()),
op.op()
.request
.iter()
.filter_map(|request| match request {
IrRequest::Json(ty) => Some(ty),
IrRequest::Multipart => None,
})
.flat_map(|ty| ty.visit()),
op.op()
.response
.iter()
.map(|response| match response {
IrResponse::Json(ty) => ty,
})
.flat_map(|ty| ty.visit()),
)
})
.map(|ty: &InlineIrType<'_>| match ty {
InlineIrType::Enum(path, ty) => {
CodegenEnum::new(CodegenTypeName::Inline(path), ty).into_token_stream()
}
InlineIrType::Struct(path, ty) => {
CodegenStruct::new(self.context, CodegenTypeName::Inline(path), ty)
.into_token_stream()
}
InlineIrType::Untagged(path, ty) => {
CodegenUntagged::new(self.context, CodegenTypeName::Inline(path), ty)
.into_token_stream()
}
});
let fields_module = inlines.next().map(|head| {
quote! {
pub mod types {
#head
#(#inlines)*
}
}
});
tokens.append_all(quote! {
#[cfg(feature = #feature_name)]
impl crate::client::Client {
#(#methods)*
}
#fields_module
});
}
}
impl IntoCode for CodegenResource<'_> {
type Code = (String, TokenStream);
fn into_code(self) -> Self::Code {
(
format!("src/client/{}.rs", self.resource.to_snake_case()),
self.into_token_stream(),
)
}
}
+135
View File
@@ -0,0 +1,135 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::{
codegen::IntoCode,
ir::{InlineIrType, IrType, SchemaIrType},
};
use super::{
context::CodegenContext, enum_::CodegenEnum, naming::CodegenTypeName, ref_::CodegenRef,
struct_::CodegenStruct, tagged::CodegenTagged, untagged::CodegenUntagged,
};
/// Generates a module for a named schema type.
#[derive(Clone, Copy, Debug)]
pub struct CodegenSchemaType<'a> {
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a SchemaIrType<'a>,
}
impl<'a> CodegenSchemaType<'a> {
pub fn new(
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a SchemaIrType<'a>,
) -> Self {
Self { context, name, ty }
}
}
impl ToTokens for CodegenSchemaType<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let n = self.name;
let code = match self.ty {
SchemaIrType::Struct(_, ty) => {
CodegenStruct::new(self.context, n, ty).into_token_stream()
}
SchemaIrType::Enum(_, ty) => CodegenEnum::new(n, ty).into_token_stream(),
SchemaIrType::Tagged(_, ty) => {
CodegenTagged::new(self.context, n, ty).into_token_stream()
}
SchemaIrType::Untagged(_, ty) => {
CodegenUntagged::new(self.context, n, ty).into_token_stream()
}
};
let mut inlines = self.ty.visit().map(|ty: &InlineIrType<'_>| match ty {
InlineIrType::Enum(path, ty) => {
CodegenEnum::new(CodegenTypeName::Inline(path), ty).into_token_stream()
}
InlineIrType::Struct(path, ty) => {
CodegenStruct::new(self.context, CodegenTypeName::Inline(path), ty)
.into_token_stream()
}
InlineIrType::Untagged(path, ty) => {
CodegenUntagged::new(self.context, CodegenTypeName::Inline(path), ty)
.into_token_stream()
}
});
let fields_module = inlines.next().map(|head| {
quote! {
pub mod fields {
#head
#(#inlines)*
}
}
});
tokens.append_all(quote! {
#code
#fields_module
});
}
}
impl IntoCode for CodegenSchemaType<'_> {
type Code = (String, TokenStream);
fn into_code(self) -> Self::Code {
let name = match self.name {
CodegenTypeName::Schema(name, _) => {
let info = &self.context.map.0[name];
format!("src/types/{}.rs", info.module)
}
CodegenTypeName::Inline(..) => {
unreachable!("inline types shouldn't be written to disk")
}
};
(name, self.into_token_stream())
}
}
/// Generates a module for a named schema type alias.
#[derive(Clone, Copy, Debug)]
pub struct CodegenSchemaTypeAlias<'a> {
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a IrType<'a>,
}
impl<'a> CodegenSchemaTypeAlias<'a> {
pub fn new(
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a IrType<'a>,
) -> Self {
Self { context, name, ty }
}
}
impl ToTokens for CodegenSchemaTypeAlias<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let ty_name = self.name;
let ty_v = CodegenRef::new(self.context, self.ty);
tokens.append_all(quote! {
pub type #ty_name = #ty_v;
});
}
}
impl IntoCode for CodegenSchemaTypeAlias<'_> {
type Code = (String, TokenStream);
fn into_code(self) -> Self::Code {
let name = match self.name {
CodegenTypeName::Schema(name, _) => {
let info = &self.context.map.0[name];
format!("src/types/{}.rs", info.module)
}
CodegenTypeName::Inline(..) => {
unreachable!("inline types shouldn't be written to disk")
}
};
(name, self.into_token_stream())
}
}
+331
View File
@@ -0,0 +1,331 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, format_ident, quote};
use crate::codegen::IntoCode;
#[derive(Clone, Copy, Debug)]
pub struct CodegenLibrary;
impl ToTokens for CodegenLibrary {
fn to_tokens(&self, tokens: &mut TokenStream) {
tokens.append_all(quote! {
pub mod absent;
pub mod date_time;
pub mod types;
pub mod client;
pub mod error;
pub use client::Client;
pub use error::Error;
});
}
}
impl IntoCode for CodegenLibrary {
type Code = (&'static str, TokenStream);
fn into_code(self) -> Self::Code {
("src/lib.rs", self.into_token_stream())
}
}
#[derive(Clone, Copy, Debug)]
pub struct CodegenDateTimeModule;
impl ToTokens for CodegenDateTimeModule {
fn to_tokens(&self, tokens: &mut TokenStream) {
#[derive(Clone, Copy)]
enum Conversion {
TryFrom(&'static str),
From(&'static str),
}
const TYPES: &[(&str, &str, Conversion)] = &[
(
"UnixMicroseconds",
"chrono::serde::ts_microseconds",
Conversion::TryFrom("from_timestamp_micros"),
),
(
"UnixMilliseconds",
"chrono::serde::ts_milliseconds",
Conversion::TryFrom("from_timestamp_millis"),
),
(
"UnixNanoseconds",
"chrono::serde::ts_nanoseconds",
Conversion::From("from_timestamp_nanos"),
),
(
"UnixSeconds",
"chrono::serde::ts_seconds",
Conversion::TryFrom("from_timestamp_secs"),
),
];
let types = TYPES.iter().map(|&(ty, module, conv)| {
let ty = format_ident!("{ty}");
let conv = match conv {
Conversion::TryFrom(init) => {
let init = format_ident!("{init}");
quote! {
impl TryFrom<i64> for #ty {
type Error = TryFromTimestampError;
fn try_from(value: i64) -> Result<Self, Self::Error> {
Ok(Self(DateTime::#init(value).ok_or(TryFromTimestampError)?))
}
}
}
}
Conversion::From(init) => {
let init = format_ident!("{init}");
quote! {
impl From<i64> for #ty {
fn from(value: i64) -> Self {
Self(DateTime::#init(value))
}
}
}
}
};
quote! {
#[derive(
Clone, Copy, Debug, Deserialize, Default, Eq, Hash, Ord,
PartialEq, PartialOrd, Serialize,
)]
#[serde(transparent)]
pub struct #ty(#[serde(with = #module)] DateTime<Utc>);
impl From<DateTime<Utc>> for #ty {
fn from(value: DateTime<Utc>) -> Self {
Self(value)
}
}
impl From<#ty> for DateTime<Utc> {
fn from(value: #ty) -> Self {
value.0
}
}
#conv
}
});
tokens.append_all(quote! {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#(#types)*
#[derive(Debug, thiserror::Error)]
#[error("timestamp out of range for `DateTime<Utc>`")]
pub struct TryFromTimestampError;
});
}
}
impl IntoCode for CodegenDateTimeModule {
type Code = (&'static str, TokenStream);
fn into_code(self) -> Self::Code {
("src/date_time.rs", self.into_token_stream())
}
}
#[derive(Clone, Copy, Debug)]
pub struct CodegenAbsentModule;
impl ToTokens for CodegenAbsentModule {
fn to_tokens(&self, tokens: &mut TokenStream) {
tokens.append_all(quote! {
use std::marker::PhantomData;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// An [`Option`]-like type that distinguishes between
/// "value not present" and "value present but `null`".
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AbsentOr<T> {
#[default]
Absent,
Null,
Present(T),
}
impl<T> AbsentOr<T> {
#[inline]
pub fn is_absent(&self) -> bool {
matches!(self, Self::Absent)
}
#[inline]
pub fn is_null(&self) -> bool {
matches!(self, Self::Null)
}
#[inline]
pub fn is_present(&self) -> bool {
matches!(self, Self::Present(_))
}
#[inline]
pub fn ok(self) -> Result<T, AbsentError> {
match self {
Self::Absent => Err(AbsentError::Absent),
Self::Null => Err(AbsentError::Null),
Self::Present(value) => Ok(value),
}
}
#[inline]
pub fn as_ref(&self) -> AbsentOr<&T> {
match self {
Self::Absent => AbsentOr::Absent,
Self::Null => AbsentOr::Null,
Self::Present(value) => AbsentOr::Present(value),
}
}
#[inline]
pub fn into_option(self) -> Option<T> {
match self {
Self::Absent | Self::Null => None,
Self::Present(value) => Some(value),
}
}
}
impl<T> From<T> for AbsentOr<T> {
fn from(value: T) -> Self {
Self::Present(value)
}
}
impl<T: Serialize> Serialize for AbsentOr<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Absent | Self::Null => serializer.serialize_none(),
Self::Present(value) => serializer.serialize_some(value),
}
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for AbsentOr<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor<T>(PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
type Value = AbsentOr<T>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("`null` or value")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(AbsentOr::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(AbsentOr::Null)
}
fn visit_some<D: Deserializer<'de>>(
self,
deserializer: D,
) -> Result<Self::Value, D::Error> {
T::deserialize(deserializer).map(AbsentOr::Present)
}
}
deserializer.deserialize_option(Visitor(PhantomData))
}
}
#[derive(Debug, thiserror::Error)]
pub enum AbsentError {
#[error("value not present")]
Absent,
#[error("value is `null`")]
Null,
}
impl AbsentError {
#[inline]
pub fn field(self, name: &'static str) -> FieldAbsentError {
match self {
Self::Absent => FieldAbsentError::Absent(name),
Self::Null => FieldAbsentError::Null(name),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum FieldAbsentError {
#[error("field `{0}` not present")]
Absent(&'static str),
#[error("field `{0}` is `null`")]
Null(&'static str),
}
});
}
}
impl IntoCode for CodegenAbsentModule {
type Code = (&'static str, TokenStream);
fn into_code(self) -> Self::Code {
("src/absent.rs", self.into_token_stream())
}
}
#[derive(Clone, Copy, Debug)]
pub struct CodegenErrorModule;
impl ToTokens for CodegenErrorModule {
fn to_tokens(&self, tokens: &mut TokenStream) {
tokens.append_all(quote! {
/// Transport-level error types.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Network or connection error.
#[error("Network error")]
Network(#[from] reqwest::Error),
/// Invalid JSON in request or response.
#[error("Malformed JSON")]
Json(#[from] JsonError),
/// Invalid URL.
#[error("Malformed URL")]
Url(#[from] url::ParseError),
/// URL can't be used as a base.
#[error("Can't use URL as base URL")]
UrlCannotBeABase,
/// Invalid HTTP header name.
#[error("invalid header name")]
BadHeaderName(#[source] http::Error),
/// Invalid HTTP header value.
#[error("invalid value for header `{0}`")]
BadHeaderValue(http::HeaderName, #[source] http::Error),
}
/// Invalid or unexpected JSON, with or without a path
/// to the unexpected section.
#[derive(Debug, thiserror::Error)]
pub enum JsonError {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
JsonWithPath(#[from] serde_path_to_error::Error<serde_json::Error>),
}
});
}
}
impl IntoCode for CodegenErrorModule {
type Code = (&'static str, TokenStream);
fn into_code(self) -> Self::Code {
("src/error.rs", self.into_token_stream())
}
}
+135
View File
@@ -0,0 +1,135 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use syn::{Ident, parse_quote};
use crate::{
codegen::{rust::CodegenIdent, unique::UniqueNameSpace},
ir::{IrStruct, IrType},
};
use super::{
context::CodegenContext, derives::ExtraDerive, doc_attrs, naming::CodegenTypeName,
ref_::CodegenBoxedRef,
};
#[derive(Clone, Copy, Debug)]
pub struct CodegenStruct<'a> {
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a IrStruct<'a>,
}
impl<'a> CodegenStruct<'a> {
pub fn new(
context: &'a CodegenContext,
name: CodegenTypeName<'a>,
ty: &'a IrStruct<'a>,
) -> Self {
Self { context, name, ty }
}
}
impl ToTokens for CodegenStruct<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let mut space = UniqueNameSpace::new();
let mut all_optional = true;
let fields = self
.ty
.fields
.iter()
.filter(|field| !field.discriminator)
.map(|field| {
let field_name = {
let name = CodegenIdent::Field(&space.uniquify(field.name));
parse_quote!(#name)
};
if field.required {
all_optional = false;
}
let final_type = match (&field.ty, field.required) {
(IrType::Nullable(inner), true) => {
let inner = CodegenBoxedRef::new(self.context, self.name, inner);
quote! { ::std::option::Option<#inner> }
}
(IrType::Nullable(inner), false) => {
let inner = CodegenBoxedRef::new(self.context, self.name, inner);
quote! { crate::absent::AbsentOr<#inner> }
}
(other, true) => {
CodegenBoxedRef::new(self.context, self.name, other).into_token_stream()
}
(other, false) => {
let inner = CodegenBoxedRef::new(self.context, self.name, other);
quote! { crate::absent::AbsentOr<#inner> }
}
};
let serde_attrs = field_serde_attrs(
&field_name,
field.name,
field.required,
matches!(field.ty, IrType::Nullable(_)),
);
let doc_attrs = field.description.map(doc_attrs);
quote! {
#doc_attrs
#serde_attrs
pub #field_name: #final_type,
}
})
.collect::<Vec<_>>();
let mut extra_derives = vec![];
let is_hashable = self
.ty
.fields
.iter()
.all(|variant| self.context.hashable(&variant.ty));
if is_hashable {
extra_derives.push(ExtraDerive::Eq);
extra_derives.push(ExtraDerive::Hash);
}
if all_optional && !fields.is_empty() {
extra_derives.push(ExtraDerive::Default);
}
let type_name = &self.name;
let doc_attrs = self.ty.description.map(doc_attrs);
tokens.append_all(quote! {
#doc_attrs
#[derive(Debug, Clone, PartialEq, #(#extra_derives,)* ::serde::Serialize, ::serde::Deserialize)]
pub struct #type_name {
#(#fields)*
}
});
}
}
/// Generates `#[serde(...)]` attributes for a field.
fn field_serde_attrs(ident: &Ident, name: &str, required: bool, nullable: bool) -> TokenStream {
let mut attrs = Vec::new();
// `rename` if the field name doesn't match the identifier.
let f = ident.to_string();
if f.strip_prefix("r#").unwrap_or(&f) != name {
attrs.push(quote! { rename = #name });
}
match (required, nullable) {
(false, true) | (false, false) => {
attrs.push(quote! { default });
attrs.push(quote! { skip_serializing_if = "crate::absent::AbsentOr::is_absent" });
}
_ => {}
}
if attrs.is_empty() {
quote! {}
} else {
quote! { #[serde(#(#attrs,)*)] }
}
}
+110
View File
@@ -0,0 +1,110 @@
use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::{
codegen::{rust::CodegenIdent, unique::UniqueNameSpace},
ir::IrTagged,
};
use super::{
context::CodegenContext, derives::ExtraDerive, doc_attrs, naming::CodegenTypeName,
ref_::CodegenRef,
};
#[derive(Clone, Copy, Debug)]
pub struct CodegenTagged<'a> {
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a IrTagged<'a>,
}
impl<'a> CodegenTagged<'a> {
pub fn new(
context: &'a CodegenContext,
name: CodegenTypeName<'a>,
ty: &'a IrTagged<'a>,
) -> Self {
Self { context, name, ty }
}
}
impl ToTokens for CodegenTagged<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let mut extra_derives = vec![];
let is_hashable = self
.ty
.variants
.iter()
.all(|variant| self.context.hashable(&variant.ty));
if is_hashable {
extra_derives.push(ExtraDerive::Eq);
extra_derives.push(ExtraDerive::Hash);
}
let mut space = UniqueNameSpace::new();
let variants = self
.ty
.variants
.iter()
.map(|variant| {
// Look up the proper Rust type name.
let variant_name = CodegenIdent::Variant(&space.uniquify(variant.name));
let rust_type_name = CodegenRef::new(self.context, &variant.ty);
// Add `#[serde(alias = ...)]` attributes for multiple
// discriminator values that map to the same type.
let serde_attr = {
let mut iter = variant.aliases.iter();
match iter.next() {
Some(&primary) => {
let mut aliases = iter.copied().peekable();
Some(if aliases.peek().is_none() {
quote! { #[serde(rename = #primary)] }
} else {
quote! { #[serde(rename = #primary, #(alias = #aliases,)*)] }
})
}
None => None,
}
};
let v = quote! {
#serde_attr
#variant_name(#rust_type_name),
};
let type_name = &self.name;
let from_impl = quote! {
impl ::std::convert::From<#rust_type_name> for #type_name {
fn from(value: #rust_type_name) -> Self {
Self::#variant_name(value)
}
}
};
(v, from_impl)
})
.collect_vec();
let discriminator_field_literal = &self.ty.tag;
let doc_attrs = self.ty.description.map(doc_attrs);
let vs = variants.iter().map(|(variant, _)| variant);
let fs = variants.iter().map(|(_, from_impl)| from_impl);
let type_name = &self.name;
let main = quote! {
#doc_attrs
#[derive(Debug, Clone, PartialEq, #(#extra_derives,)* ::serde::Serialize, ::serde::Deserialize)]
#[serde(tag = #discriminator_field_literal)]
pub enum #type_name {
#(#vs)*
}
#(#fs)*
};
tokens.append_all(main);
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::collections::{BTreeMap, BTreeSet};
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::codegen::IntoCode;
use super::context::CodegenContext;
/// Generates the `types/mod.rs` module.
pub struct CodegenTypesModule<'a> {
context: &'a CodegenContext<'a>,
resources_by_type: BTreeMap<&'a str, BTreeSet<&'a str>>,
}
impl<'a> CodegenTypesModule<'a> {
pub fn new(context: &'a CodegenContext<'a>) -> Self {
let mut resources_by_type = BTreeMap::<&str, BTreeSet<&str>>::new();
for view in context.spec.operations() {
let resource = view.op().resource;
for v in view.refs() {
resources_by_type
.entry(v.name())
.or_default()
.insert(resource);
}
}
Self {
context,
resources_by_type,
}
}
}
impl ToTokens for CodegenTypesModule<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let mut mods = Vec::new();
let mut uses = Vec::new();
for (name, info) in self.context.map.iter() {
let Some(resources) = self.resources_by_type.get(name) else {
continue;
};
let cfg_attr = cfg_attr(resources);
let module = &info.module;
mods.push(quote! {
#cfg_attr
pub mod #module;
});
let ty = &info.ty;
uses.push(quote! {
#cfg_attr
pub use #module::#ty;
});
}
tokens.append_all(quote! {
#(#mods)*
#(#uses)*
});
}
}
impl IntoCode for CodegenTypesModule<'_> {
type Code = (&'static str, TokenStream);
fn into_code(self) -> Self::Code {
("src/types/mod.rs", self.into_token_stream())
}
}
/// Generates a `#[cfg(feature = "...")]` or `#[cfg(any(feature = "...", ...))]`
/// attribute for the given resources.
fn cfg_attr(resources: &BTreeSet<&str>) -> Option<TokenStream> {
let mut features = resources.iter().peekable();
let first = features.next()?;
Some(match features.next() {
Some(next) => {
let rest = features.map(|f| quote! { feature = #f });
quote! { #[cfg(any(feature = #first, feature = #next, #(#rest),*))] }
}
None => quote! { #[cfg(feature = #first)] },
})
}
+68
View File
@@ -0,0 +1,68 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, TokenStreamExt, quote};
use crate::ir::{IrUntagged, IrUntaggedVariant};
use super::{
context::CodegenContext,
derives::ExtraDerive,
doc_attrs,
naming::{CodegenTypeName, CodegenUntaggedVariantName},
ref_::CodegenRef,
};
#[derive(Clone, Copy, Debug)]
pub struct CodegenUntagged<'a> {
context: &'a CodegenContext<'a>,
name: CodegenTypeName<'a>,
ty: &'a IrUntagged<'a>,
}
impl<'a> CodegenUntagged<'a> {
pub fn new(
context: &'a CodegenContext,
name: CodegenTypeName<'a>,
ty: &'a IrUntagged<'a>,
) -> Self {
Self { context, name, ty }
}
}
impl ToTokens for CodegenUntagged<'_> {
fn to_tokens(&self, tokens: &mut TokenStream) {
let mut variants = Vec::new();
for variant in &self.ty.variants {
match variant {
IrUntaggedVariant::Some(name, ty) => {
let variant_name = CodegenUntaggedVariantName(*name);
let rust_type = CodegenRef::new(self.context, ty);
variants.push(quote! { #variant_name(#rust_type) });
}
IrUntaggedVariant::Null => variants.push(quote! { None }),
}
}
let type_name_ident = &self.name;
let doc_attrs = self.ty.description.map(doc_attrs);
let mut extra_derives = vec![];
let is_hashable = self.ty.variants.iter().all(|variant| match variant {
IrUntaggedVariant::Some(_, ty) => self.context.hashable(ty),
IrUntaggedVariant::Null => true,
});
if is_hashable {
extra_derives.push(ExtraDerive::Eq);
extra_derives.push(ExtraDerive::Hash);
}
tokens.append_all(quote! {
#doc_attrs
#[derive(Debug, Clone, PartialEq, #(#extra_derives,)* ::serde::Serialize, ::serde::Deserialize)]
#[serde(untagged)]
pub enum #type_name_ident {
#(#variants),*
}
})
}
}
+322
View File
@@ -0,0 +1,322 @@
use std::borrow::Cow;
use std::collections::btree_map::Entry;
use std::str::CharIndices;
use std::{collections::BTreeMap, iter::Peekable};
use unicase::UniCase;
/// Produces names that will never collide with other names in this space,
/// even when converted to a different case.
///
/// [`UniqueNameSpace`] exists to disambiguate type and field names
/// that are distinct in the source spec, but collide when transformed
/// to a different case. (For example, both `HTTP_Response` and `HTTPResponse`
/// become `http_response` in snake case).
#[derive(Debug, Default)]
pub struct UniqueNameSpace<'a>(BTreeMap<Box<[UniCase<&'a str>]>, usize>);
impl<'a> UniqueNameSpace<'a> {
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Returns a unique name, ignoring case and case transformations.
/// The unique name preserves the case of the original name, but adds
/// a numeric suffix on collisions.
///
/// # Examples
///
/// ```
/// # use ploidy::codegen::unique::UniqueNameSpace;
/// # let mut space = UniqueNameSpace::new();
/// assert_eq!(space.uniquify("HTTPResponse"), "HTTPResponse");
/// assert_eq!(space.uniquify("HTTP_Response"), "HTTP_Response2");
/// assert_eq!(space.uniquify("httpResponse"), "httpResponse3");
/// ```
#[inline]
pub fn uniquify(&mut self, name: &'a str) -> Cow<'a, str> {
match self
.0
.entry(WordSegments::new(name).map(UniCase::new).collect())
{
Entry::Occupied(mut entry) => {
let count = entry.get_mut();
*count += 1;
format!("{name}{count}").into()
}
Entry::Vacant(entry) => {
entry.insert(1);
name.into()
}
}
}
}
/// Segments a string into words, following Heck's notion of word boundaries.
///
/// # Examples
///
/// ```
/// # use itertools::Itertools;
/// # use ploidy::codegen::unique::WordSegments;
/// assert_eq!(WordSegments::new("HTTPResponse").collect_vec(), vec!["HTTP", "Response"]);
/// assert_eq!(WordSegments::new("HTTP_Response").collect_vec(), vec!["HTTP", "Response"]);
/// assert_eq!(WordSegments::new("httpResponse").collect_vec(), vec!["http", "Response"]);
/// assert_eq!(WordSegments::new("XMLHttpRequest").collect_vec(), vec!["XML", "Http", "Request"]);
pub struct WordSegments<'a> {
input: &'a str,
chars: Peekable<CharIndices<'a>>,
current_word_starts_at: Option<usize>,
mode: WordMode,
}
impl<'a> WordSegments<'a> {
#[inline]
pub fn new(input: &'a str) -> Self {
Self {
input,
chars: input.char_indices().peekable(),
current_word_starts_at: None,
mode: WordMode::Boundary,
}
}
}
impl<'a> Iterator for WordSegments<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
while let Some((index, c)) = self.chars.next() {
if c.is_uppercase() {
match self.mode {
WordMode::Boundary => {
// Start a new word with this uppercase character.
self.current_word_starts_at = Some(index);
self.mode = WordMode::Uppercase;
}
WordMode::Lowercase => {
// camelCased word (previous was lowercase;
// current is uppercase), start a new word.
let start = self.current_word_starts_at.replace(index);
self.mode = WordMode::Uppercase;
if let Some(start) = start {
return Some(&self.input[start..index]);
}
}
WordMode::Uppercase => {
let next_is_lowercase = self
.chars
.peek()
.map(|&(_, next)| next.is_lowercase())
.unwrap_or(false);
if next_is_lowercase && let Some(start) = self.current_word_starts_at {
// `XMLHttp` case; start a new word with this uppercase
// character (the "H" in "Http").
self.current_word_starts_at = Some(index);
return Some(&self.input[start..index]);
}
// (Stay in uppercase mode).
}
}
} else if c.is_lowercase() {
if self.current_word_starts_at.is_none() {
// Start a new word with this lowercase character
// (the "c" in "camelCase").
self.current_word_starts_at = Some(index);
}
self.mode = WordMode::Lowercase;
} else if !c.is_alphanumeric() {
// Start a new word at this non-alphanumeric character.
let start = std::mem::take(&mut self.current_word_starts_at);
self.mode = WordMode::Boundary;
if let Some(start) = start {
return Some(&self.input[start..index]);
}
} else {
// Digit or other character: continue the current word.
if self.current_word_starts_at.is_none() {
self.current_word_starts_at = Some(index);
}
}
}
if let Some(start) = std::mem::take(&mut self.current_word_starts_at) {
// Trailing word.
return Some(&self.input[start..]);
}
None
}
}
/// The current state of a [`WordSegments`] iterator.
#[derive(Clone, Copy)]
enum WordMode {
/// At a word boundary: either at the start of a new word, or
/// after a non-alphanumeric character.
Boundary,
/// Currently in a lowercase segment.
Lowercase,
/// Currently in an uppercase segment.
Uppercase,
}
#[cfg(test)]
mod tests {
use super::*;
use itertools::Itertools;
#[test]
fn test_segment_camel_case() {
assert_eq!(
WordSegments::new("camelCase").collect_vec(),
vec!["camel", "Case"]
);
assert_eq!(
WordSegments::new("httpResponse").collect_vec(),
vec!["http", "Response"]
);
}
#[test]
fn test_segment_pascal_case() {
assert_eq!(
WordSegments::new("PascalCase").collect_vec(),
vec!["Pascal", "Case"]
);
assert_eq!(
WordSegments::new("HttpResponse").collect_vec(),
vec!["Http", "Response"]
);
}
#[test]
fn test_segment_snake_case() {
assert_eq!(
WordSegments::new("snake_case").collect_vec(),
vec!["snake", "case"]
);
assert_eq!(
WordSegments::new("http_response").collect_vec(),
vec!["http", "response"]
);
}
#[test]
fn test_segment_screaming_snake() {
assert_eq!(
WordSegments::new("SCREAMING_SNAKE").collect_vec(),
vec!["SCREAMING", "SNAKE"]
);
assert_eq!(
WordSegments::new("HTTP_RESPONSE").collect_vec(),
vec!["HTTP", "RESPONSE"]
);
}
#[test]
fn test_segment_consecutive_uppercase() {
assert_eq!(
WordSegments::new("XMLHttpRequest").collect_vec(),
vec!["XML", "Http", "Request"]
);
assert_eq!(
WordSegments::new("HTTPResponse").collect_vec(),
vec!["HTTP", "Response"]
);
assert_eq!(
WordSegments::new("HTTP_Response").collect_vec(),
vec!["HTTP", "Response"]
);
assert_eq!(WordSegments::new("ALLCAPS").collect_vec(), vec!["ALLCAPS"]);
}
#[test]
fn test_segment_with_numbers() {
assert_eq!(
WordSegments::new("Response2").collect_vec(),
vec!["Response2"]
);
assert_eq!(
WordSegments::new("response_2").collect_vec(),
vec!["response", "2"]
);
assert_eq!(
WordSegments::new("HTTP2Protocol").collect_vec(),
vec!["HTTP2", "Protocol"]
);
assert_eq!(
WordSegments::new("OAuth2Token").collect_vec(),
vec!["O", "Auth2", "Token"]
);
assert_eq!(
WordSegments::new("HTTP2XML").collect_vec(),
vec!["HTTP2XML"]
);
}
#[test]
fn test_segment_empty_and_special() {
assert!(WordSegments::new("").collect_vec().is_empty());
assert!(WordSegments::new("___").collect_vec().is_empty());
assert_eq!(WordSegments::new("a").collect_vec(), vec!["a"]);
assert_eq!(WordSegments::new("A").collect_vec(), vec!["A"]);
}
#[test]
fn test_segment_mixed_separators() {
assert_eq!(
WordSegments::new("foo-bar_baz").collect_vec(),
vec!["foo", "bar", "baz"]
);
assert_eq!(
WordSegments::new("foo--bar").collect_vec(),
vec!["foo", "bar"]
);
}
#[test]
fn test_deduplication_http_response_collision() {
let mut space = UniqueNameSpace::new();
assert_eq!(space.uniquify("HTTPResponse"), "HTTPResponse");
assert_eq!(space.uniquify("HTTP_Response"), "HTTP_Response2");
assert_eq!(space.uniquify("httpResponse"), "httpResponse3");
assert_eq!(space.uniquify("http_response"), "http_response4");
// `HTTPRESPONSE` isn't a collision; it's a single word.
assert_eq!(space.uniquify("HTTPRESPONSE"), "HTTPRESPONSE");
}
#[test]
fn test_deduplication_xml_http_request() {
let mut space = UniqueNameSpace::new();
assert_eq!(space.uniquify("XMLHttpRequest"), "XMLHttpRequest");
assert_eq!(space.uniquify("xml_http_request"), "xml_http_request2");
assert_eq!(space.uniquify("XmlHttpRequest"), "XmlHttpRequest3");
}
#[test]
fn test_deduplication_preserves_original_casing() {
let mut space = UniqueNameSpace::new();
assert_eq!(space.uniquify("HTTP_Response"), "HTTP_Response");
assert_eq!(space.uniquify("httpResponse"), "httpResponse2");
}
#[test]
fn test_deduplication_same_prefix() {
let mut dedup = UniqueNameSpace::new();
assert_eq!(dedup.uniquify("HttpRequest"), "HttpRequest");
assert_eq!(dedup.uniquify("HttpResponse"), "HttpResponse");
assert_eq!(dedup.uniquify("HttpError"), "HttpError");
}
#[test]
fn test_deduplication_with_numbers() {
let mut space = UniqueNameSpace::new();
assert_eq!(space.uniquify("Response2"), "Response2");
assert_eq!(space.uniquify("response_2"), "response_2");
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::{io::ErrorKind as IoErrorKind, path::PathBuf};
use clap::{
CommandFactory, FromArgMatches,
error::{ErrorKind as ClapErrorKind, Result as ClapResult},
};
use semver::Version;
use serde::Deserialize;
#[derive(Debug, clap::Parser)]
#[command(version, about, long_about = None)]
pub struct Main {
/// Enable verbose logging.
#[arg(short, long)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}
impl Main {
pub fn parse() -> ClapResult<Main> {
let mut cmd = Self::command();
let mut matches = cmd
.try_get_matches_from_mut(std::env::args_os())
.map_err(|err| err.format(&mut cmd))?;
let args = Self::from_arg_matches_mut(&mut matches).map_err(|err| err.format(&mut cmd))?;
let command = match args.command {
Command::Codegen(Codegen {
input,
output,
language,
}) => {
let file: Option<ConfigFileSections> = {
let path = output.join(".ploidy.toml");
match std::fs::read_to_string(&path) {
Ok(contents) => Some(toml::from_str(&contents).map_err(|err| {
cmd.error(
ClapErrorKind::ValueValidation,
format!("Failed to parse `{}`: {err}", path.display()),
)
})?),
Err(err) if err.kind() == IoErrorKind::NotFound => None,
Err(err) => {
return Err(cmd.error(
ClapErrorKind::Io,
format!("Failed to read `{}`: {err}", path.display()),
));
}
}
};
match language {
CodegenLanguage::Rust(rust) => {
let package = match file {
Some(ConfigFileSections {
rust:
Some(RustConfigFileSection {
package: Some(other),
}),
}) => rust.package.merge(other),
_ => rust.package,
};
Command::Codegen(Codegen {
input,
output,
language: CodegenLanguage::Rust(CodegenRust { package, ..rust }),
})
}
}
}
};
Ok(Main {
verbose: args.verbose,
command,
})
}
}
#[derive(Debug, clap::Subcommand)]
pub enum Command {
/// Generate a client from an OpenAPI document.
Codegen(Codegen),
}
#[derive(Debug, clap::Args)]
pub struct Codegen {
/// The path to the OpenAPI document (`.yaml` or `.json`).
pub input: PathBuf,
/// The output directory for the generated files.
pub output: PathBuf,
#[command(subcommand)]
pub language: CodegenLanguage,
}
#[derive(Debug, clap::Subcommand)]
pub enum CodegenLanguage {
/// Generate a Rust package.
Rust(CodegenRust),
}
#[derive(Debug, Default, clap::Args)]
pub struct CodegenRust {
/// Run `cargo check` on the generated code.
#[arg(short, long)]
pub check: bool,
#[command(flatten)]
pub package: RustPackageFragment,
}
#[derive(Debug, Default, Deserialize, clap::Args)]
#[command(next_help_heading = "Generated package options")]
pub struct RustPackageFragment {
/// The generated package name. Defaults to the output directory name
/// if not set.
#[arg(name = "package-name", long)]
#[serde(default)]
pub name: Option<String>,
/// The generated package version. Defaults to `0.0.0` if not set.
#[arg(name = "package-version", long)]
#[serde(default)]
pub version: Option<Version>,
/// The generated package description.
#[arg(name = "package-description", long)]
#[serde(default)]
pub description: Option<String>,
/// The generated package license. Defaults to `UNLICENSED` if not set.
#[arg(name = "package-license", long)]
#[serde(default)]
pub license: Option<String>,
}
impl RustPackageFragment {
#[inline]
pub fn merge(self, other: Self) -> Self {
Self {
name: self.name.or(other.name),
version: self.version.or(other.version),
description: self.description.or(other.description),
license: self.license.or(other.license),
}
}
#[inline]
pub fn version_or_default(&self) -> Version {
self.version
.clone()
.unwrap_or_else(|| Version::new(0, 0, 0))
}
#[inline]
pub fn license_or_default(&self) -> &str {
self.license.as_deref().unwrap_or("UNLICENSED")
}
}
#[derive(Debug, Deserialize)]
pub struct ConfigFileSections {
#[serde(default)]
pub rust: Option<RustConfigFileSection>,
}
#[derive(Debug, Deserialize)]
pub struct RustConfigFileSection {
#[serde(default)]
pub package: Option<RustPackageFragment>,
}
+11
View File
@@ -0,0 +1,11 @@
#[derive(Debug, thiserror::Error)]
pub enum SerdeError {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
JsonWithPath(#[from] serde_path_to_error::Error<serde_json::Error>),
#[error(transparent)]
Yaml(#[from] serde_yaml::Error),
#[error(transparent)]
YamlWithPath(#[from] serde_path_to_error::Error<serde_yaml::Error>),
}
+9
View File
@@ -0,0 +1,9 @@
use crate::parse::path::BadPath;
#[derive(Debug, thiserror::Error)]
pub enum IrError {
#[error("can't generate code for an operation without an ID")]
NoOperationId,
#[error("operation has invalid path")]
BadOperationPath(#[from] BadPath),
}
+328
View File
@@ -0,0 +1,328 @@
use std::collections::BTreeSet;
use indexmap::IndexMap;
use crate::parse::{Document, RefOrSchema, Schema, SchemaRefPath};
/// Yields all the fields of this schema, including inherited fields.
pub fn all_fields<'a>(
doc: &'a Document,
schema: &'a Schema,
) -> impl Iterator<Item = (&'a str, IrSchemaField<'a>)> {
let ancestors = Vec::from_iter(Ancestors::new(doc, schema));
let discriminators: BTreeSet<_> = ancestors
.iter()
.filter_map(|ancestor| ancestor.discriminator.as_ref())
.map(|d| d.property_name.as_str())
.collect();
let own: IndexMap<_, _> = schema
.properties
.iter()
.flatten()
.map(|(name, property)| {
(
name.as_str(),
IrSchemaField::Own(IrSchemaFieldInfo {
schema: property,
required: schema.required.contains(name),
discriminator: discriminators.contains(name.as_str()),
}),
)
})
.collect();
let mut inherited = IndexMap::new();
for ancestor in ancestors {
let properties = ancestor
.properties
.iter()
.flatten()
.filter(|(name, _)| !own.contains_key(name.as_str()));
for (name, property) in properties {
inherited.entry(name.as_str()).or_insert_with(|| {
IrSchemaField::Inherited(IrSchemaFieldInfo {
schema: property,
required: ancestor.required.contains(name),
discriminator: discriminators.contains(name.as_str()),
})
});
}
}
itertools::chain!(inherited, own)
}
#[derive(Clone, Copy, Debug)]
pub enum IrSchemaField<'a> {
Inherited(IrSchemaFieldInfo<'a>),
Own(IrSchemaFieldInfo<'a>),
}
impl<'a> IrSchemaField<'a> {
#[inline]
pub fn info(self) -> IrSchemaFieldInfo<'a> {
let (Self::Inherited(info) | Self::Own(info)) = self;
info
}
}
#[derive(Clone, Copy, Debug)]
pub struct IrSchemaFieldInfo<'a> {
pub schema: &'a RefOrSchema,
pub required: bool,
pub discriminator: bool,
}
/// Yields all ancestors of a schema in linear order.
pub struct Ancestors<'a> {
doc: &'a Document,
stack: Vec<&'a RefOrSchema>,
visited: BTreeSet<&'a SchemaRefPath>,
}
impl<'a> Ancestors<'a> {
#[inline]
pub fn new(doc: &'a Document, schema: &'a Schema) -> Self {
let stack = match &schema.all_of {
// Push parents in reverse, so that the iterator will pop and
// visit them in left-to-right order.
Some(all_of) => all_of.iter().rev().collect(),
None => vec![],
};
Self {
doc,
stack,
visited: BTreeSet::new(),
}
}
}
impl<'a> Iterator for Ancestors<'a> {
type Item = &'a Schema;
fn next(&mut self) -> Option<Self::Item> {
while let Some(item) = self.stack.pop() {
let schema = match item {
RefOrSchema::Ref(r) => {
if !self.visited.insert(&r.path) {
// Skip cycles.
continue;
}
let Some(schema) = self
.doc
.components
.as_ref()
.and_then(|c| c.schemas.get(r.path.as_str()))
else {
continue;
};
schema
}
RefOrSchema::Schema(schema) => schema.as_ref(),
};
if let Some(all_of) = &schema.all_of {
self.stack.extend(all_of.iter().rev());
}
return Some(schema);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::{Components, RefOrSchema, Schema, Ty};
use indexmap::IndexMap;
use itertools::Itertools;
fn make_string_field() -> RefOrSchema {
RefOrSchema::Schema(Box::new(Schema {
ty: vec![Ty::String],
..Default::default()
}))
}
fn make_int_field() -> RefOrSchema {
RefOrSchema::Schema(Box::new(Schema {
ty: vec![Ty::Integer],
..Default::default()
}))
}
fn make_doc(schemas: IndexMap<String, Schema>) -> Document {
Document {
openapi: "3.0.0".to_string(),
info: crate::parse::Info {
title: "Test".to_string(),
version: "1.0".to_string(),
description: None,
},
paths: IndexMap::new(),
components: Some(Components { schemas }),
}
}
fn make_ref(name: &str) -> crate::parse::Ref {
crate::parse::Ref {
path: format!("#/components/schemas/{name}").parse().unwrap(),
}
}
#[test]
fn test_multi_level_inheritance() {
// Entity -> NamedEntity -> User chain
let mut schemas = IndexMap::new();
let mut entity_props = IndexMap::new();
entity_props.insert("id".to_string(), make_string_field());
schemas.insert(
"Entity".to_string(),
Schema {
properties: Some(entity_props),
..Default::default()
},
);
let mut named_entity_props = IndexMap::new();
named_entity_props.insert("name".to_string(), make_string_field());
schemas.insert(
"NamedEntity".to_string(),
Schema {
all_of: Some(vec![RefOrSchema::Ref(make_ref("Entity"))]),
properties: Some(named_entity_props),
..Default::default()
},
);
let mut user_props = IndexMap::new();
user_props.insert("email".to_string(), make_string_field());
schemas.insert(
"User".to_string(),
Schema {
all_of: Some(vec![RefOrSchema::Ref(make_ref("NamedEntity"))]),
properties: Some(user_props),
..Default::default()
},
);
let doc = make_doc(schemas);
let user_schema = doc
.components
.as_ref()
.unwrap()
.schemas
.get("User")
.unwrap();
let all_fields = all_fields(&doc, user_schema).collect_vec();
// Should have 3 fields total: id (inherited), name (inherited), email (own)
assert_eq!(all_fields.len(), 3);
// Check inherited fields
let inherited: Vec<_> = all_fields
.iter()
.filter_map(|(name, f)| match f {
IrSchemaField::Inherited(info) => Some((name, info)),
_ => None,
})
.collect();
assert_eq!(inherited.len(), 2);
let inherited_names: Vec<&str> = inherited.iter().map(|(n, _)| **n).collect();
assert!(inherited_names.contains(&"id"));
assert!(inherited_names.contains(&"name"));
// Check own fields
let own: Vec<_> = all_fields
.iter()
.filter_map(|(name, f)| match f {
IrSchemaField::Own(info) => Some((name, info)),
_ => None,
})
.collect();
assert_eq!(own.len(), 1);
assert_eq!(*own[0].0, "email");
}
#[test]
fn test_diamond_inheritance_no_duplicate() {
// Product -> [NamedEntity, Entity], NamedEntity -> Entity
// Entity should only appear once
let mut schemas = IndexMap::new();
let mut entity_props = IndexMap::new();
entity_props.insert("id".to_string(), make_string_field());
schemas.insert(
"Entity".to_string(),
Schema {
properties: Some(entity_props),
..Default::default()
},
);
let mut named_entity_props = IndexMap::new();
named_entity_props.insert("name".to_string(), make_string_field());
schemas.insert(
"NamedEntity".to_string(),
Schema {
all_of: Some(vec![RefOrSchema::Ref(make_ref("Entity"))]),
properties: Some(named_entity_props),
..Default::default()
},
);
let mut product_props = IndexMap::new();
product_props.insert("price".to_string(), make_int_field());
schemas.insert(
"Product".to_string(),
Schema {
all_of: Some(vec![
RefOrSchema::Ref(make_ref("NamedEntity")),
RefOrSchema::Ref(make_ref("Entity")),
]),
properties: Some(product_props),
..Default::default()
},
);
let doc = make_doc(schemas);
let product = doc
.components
.as_ref()
.unwrap()
.schemas
.get("Product")
.unwrap();
let all_fields = all_fields(&doc, product).collect_vec();
// Should have 3 fields total: id (inherited), name (inherited), price (own)
assert_eq!(all_fields.len(), 3);
// Should inherit: name, id (Entity only once)
let inherited: Vec<_> = all_fields
.iter()
.filter_map(|(name, f)| match f {
IrSchemaField::Inherited(info) => Some((name, info)),
_ => None,
})
.collect();
assert_eq!(inherited.len(), 2);
let inherited_names: Vec<&str> = inherited.iter().map(|(n, _)| **n).collect();
assert!(inherited_names.contains(&"id"));
assert!(inherited_names.contains(&"name"));
// Own: price
let own: Vec<_> = all_fields
.iter()
.filter_map(|(name, f)| match f {
IrSchemaField::Own(info) => Some((name, info)),
_ => None,
})
.collect();
assert_eq!(own.len(), 1);
assert_eq!(*own[0].0, "price");
}
}
+12
View File
@@ -0,0 +1,12 @@
mod error;
mod fields;
mod spec;
mod transform;
mod types;
mod visitor;
pub use fields::*;
pub use spec::*;
pub use transform::*;
pub use types::*;
pub use visitor::{InnerLeaf, InnerRef, Visitable};
+362
View File
@@ -0,0 +1,362 @@
use std::collections::BTreeSet;
use std::sync::OnceLock;
use indexmap::IndexMap;
use itertools::Itertools;
use petgraph::algo::tarjan_scc;
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::Bfs;
use crate::parse::{self, Document, Info, ParameterLocation, RefOrSchema};
use super::{
error::IrError,
transform::transform,
types::{
InlineIrTypePath, InlineIrTypePathRoot, InlineIrTypePathSegment, IrOperation, IrParameter,
IrParameterInfo, IrRequest, IrResponse, IrType, IrTypeName,
},
visitor::InnerRef,
};
#[derive(Debug)]
pub struct IrSpec<'a> {
info: &'a Info,
operations: Vec<IrOperation<'a>>,
schemas: IndexMap<&'a str, (NodeIndex, IrType<'a>)>,
refs: DiGraph<&'a str, ()>,
circular_refs: OnceLock<BTreeSet<(NodeIndex, NodeIndex)>>,
}
impl<'a> IrSpec<'a> {
pub fn from_doc(doc: &'a Document) -> Result<Self, IrError> {
let mut schemas = IndexMap::new();
let mut refs = DiGraph::new();
if let Some(components) = &doc.components {
for (name, schema) in &components.schemas {
let index = refs.add_node(name.as_str());
schemas.insert(
name.as_str(),
(index, transform(doc, IrTypeName::Schema(name), schema)),
);
}
}
for (from, ty) in schemas.values() {
for r in ty.visit::<InnerRef>() {
let &(to, _) = &schemas[r.name()];
refs.add_edge(*from, to, ());
}
}
let mut operations = vec![];
for (path, item) in &doc.paths {
let segments = parse::path::parse(path.as_str())?;
for (method, op) in item.operations() {
let resource = op.extension("x-resource-name").unwrap_or("full");
let id = op.operation_id.as_deref().ok_or(IrError::NoOperationId)?;
let params = op
.parameters
.iter()
.filter_map(|param| {
let ty = match &param.schema {
Some(RefOrSchema::Ref(r)) => IrType::Ref(r.path.as_str()),
Some(RefOrSchema::Schema(schema)) => transform(
doc,
InlineIrTypePath {
root: InlineIrTypePathRoot::Resource(resource),
segments: vec![
InlineIrTypePathSegment::Operation(id),
InlineIrTypePathSegment::Parameter(param.name.as_str()),
],
},
schema,
),
None => IrType::Any,
};
let info = IrParameterInfo {
name: param.name.as_str(),
ty,
required: param.required,
description: param.description.as_deref(),
};
Some(match param.location {
ParameterLocation::Path => IrParameter::Path(info),
ParameterLocation::Query => IrParameter::Query(info),
_ => return None,
})
})
.collect_vec();
let request = op
.request_body
.as_ref()
.map(|request| {
if request.content.contains_key("multipart/form-data") {
RequestContent::Multipart
} else if let Some(content) = request.content.get("application/json")
&& let Some(schema) = &content.schema
{
RequestContent::Json(schema)
} else if let Some(content) = request.content.get("*/*")
&& let Some(schema) = &content.schema
{
RequestContent::Json(schema)
} else {
RequestContent::Any
}
})
.map(|content| match content {
RequestContent::Multipart => IrRequest::Multipart,
RequestContent::Json(RefOrSchema::Ref(r)) => {
IrRequest::Json(IrType::Ref(r.path.as_str()))
}
RequestContent::Json(RefOrSchema::Schema(schema)) => {
IrRequest::Json(transform(
doc,
InlineIrTypePath {
root: InlineIrTypePathRoot::Resource(resource),
segments: vec![
InlineIrTypePathSegment::Operation(id),
InlineIrTypePathSegment::Request,
],
},
schema,
))
}
RequestContent::Any => IrRequest::Json(IrType::Any),
});
let response = {
let mut statuses = op
.responses
.keys()
.filter_map(|status| Some((status.as_str(), status.parse::<u16>().ok()?)))
.collect_vec();
statuses.sort_unstable_by_key(|&(_, code)| code);
let key = statuses
.iter()
.find(|&(_, code)| matches!(code, 200..300))
.map(|&(key, _)| key)
.unwrap_or("default");
op.responses
.get(key)
.and_then(|response| response.content.as_ref())
.map(|content| {
if let Some(content) = content.get("application/json")
&& let Some(schema) = &content.schema
{
ResponseContent::Json(schema)
} else if let Some(content) = content.get("*/*")
&& let Some(schema) = &content.schema
{
ResponseContent::Json(schema)
} else {
ResponseContent::Any
}
})
.map(|content| match content {
ResponseContent::Json(RefOrSchema::Ref(r)) => {
IrResponse::Json(IrType::Ref(r.path.as_str()))
}
ResponseContent::Json(RefOrSchema::Schema(schema)) => {
IrResponse::Json(transform(
doc,
InlineIrTypePath {
root: InlineIrTypePathRoot::Resource(resource),
segments: vec![
InlineIrTypePathSegment::Operation(id),
InlineIrTypePathSegment::Request,
],
},
schema,
))
}
ResponseContent::Any => IrResponse::Json(IrType::Any),
})
};
operations.push(IrOperation {
resource,
id,
method,
path: segments.clone(),
description: op.description.as_deref(),
params,
request,
response,
});
}
}
Ok(IrSpec {
info: &doc.info,
operations,
schemas,
refs,
circular_refs: OnceLock::new(),
})
}
#[inline]
pub fn info(&self) -> &'a Info {
self.info
}
/// Yields views for all operations in this spec.
#[inline]
pub fn operations(&self) -> impl Iterator<Item = IrOperationView<'_>> {
self.operations
.iter()
.map(|op| IrOperationView { spec: self, op })
}
/// Yields views for all schemas in this spec.
#[inline]
pub fn schemas(&self) -> impl Iterator<Item = IrSchemaView<'_>> {
self.schemas
.iter()
.map(|(_, &(index, _))| IrSchemaView::new(self, index))
}
/// Looks up a schema by name, and returns a view for that schema.
#[inline]
pub fn lookup(&self, name: &str) -> Option<IrSchemaView<'_>> {
self.schemas
.get(name)
.map(|&(index, _)| IrSchemaView::new(self, index))
}
/// Finds all circular reference edges in this spec. Each edge
/// in the returned set participates in a circular reference,
/// and requires indirection to break the cycle.
fn circular_refs(&self) -> &BTreeSet<(NodeIndex, NodeIndex)> {
self.circular_refs.get_or_init(|| {
let mut edges = BTreeSet::new();
let sccs = tarjan_scc(&self.refs);
for scc in sccs {
let scc = BTreeSet::from_iter(scc);
for &node in &scc {
// Collect internal edges within this
// strongly connected component.
edges.extend(
self.refs
.neighbors(node)
.filter(|neighbor| scc.contains(neighbor))
.map(|neighbor| (node, neighbor)),
);
}
}
edges
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct IrOperationView<'a> {
spec: &'a IrSpec<'a>,
op: &'a IrOperation<'a>,
}
impl<'a> IrOperationView<'a> {
#[inline]
pub fn op(self) -> &'a IrOperation<'a> {
self.op
}
pub fn refs(self) -> impl Iterator<Item = IrSchemaView<'a>> {
itertools::chain!(
// Parameter type references.
self.op
.params
.iter()
.map(|param| match param {
IrParameter::Path(info) => info,
IrParameter::Query(info) => info,
})
.flat_map(|info| info.ty.visit()),
// Request type references.
self.op
.request
.iter()
.filter_map(|request| match request {
IrRequest::Multipart => None,
IrRequest::Json(ty) => Some(ty),
})
.flat_map(|ty| ty.visit()),
// Response type references.
self.op
.response
.iter()
.map(|response| match response {
IrResponse::Json(ty) => ty,
})
.flat_map(|ty| ty.visit()),
)
.flat_map(move |r: InnerRef| {
let &(index, _) = &self.spec.schemas[r.name()];
let mut bfs = Bfs::new(&self.spec.refs, index);
std::iter::from_fn(move || bfs.next(&self.spec.refs))
})
.map(|index| IrSchemaView {
spec: self.spec,
index,
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct IrSchemaView<'a> {
spec: &'a IrSpec<'a>,
index: NodeIndex,
}
impl<'a> IrSchemaView<'a> {
#[inline]
fn new(spec: &'a IrSpec<'a>, index: NodeIndex) -> Self {
Self { spec, index }
}
#[inline]
pub fn name(self) -> &'a str {
self.spec.refs[self.index]
}
#[inline]
pub fn ty(self) -> &'a IrType<'a> {
let (_, ty) = &self.spec.schemas[self.name()];
ty
}
/// Returns `true` if a reference from this schema to the `other` schema
/// requires indirection (with [`Box`], [`Vec`], etc.)
#[inline]
pub fn requires_indirection_to(&self, other: IrSchemaView<'_>) -> bool {
self.spec
.circular_refs()
.contains(&(self.index, other.index))
}
#[inline]
pub fn refs(self) -> impl Iterator<Item = IrSchemaView<'a>> {
let mut bfs = Bfs::new(&self.spec.refs, self.index);
std::iter::from_fn(move || bfs.next(&self.spec.refs))
.map(|index| IrSchemaView::new(self.spec, index))
}
}
#[derive(Clone, Copy, Debug)]
enum RequestContent<'a> {
Multipart,
Json(&'a RefOrSchema),
Any,
}
#[derive(Clone, Copy, Debug)]
enum ResponseContent<'a> {
Json(&'a RefOrSchema),
Any,
}
+337
View File
@@ -0,0 +1,337 @@
use std::collections::BTreeMap;
use itertools::Itertools;
use crate::parse::{AdditionalProperties, Document, Format, RefOrSchema, Schema, Ty};
use super::{
fields::{IrSchemaField, all_fields},
types::{
InlineIrType, InlineIrTypePath, InlineIrTypePathRoot, InlineIrTypePathSegment, IrEnum,
IrEnumVariant, IrStruct, IrStructField, IrTagged, IrTaggedVariant, IrType, IrTypeName,
IrUntagged, IrUntaggedVariant, IrUntaggedVariantNameHint, PrimitiveIrType, SchemaIrType,
},
};
#[inline]
pub fn transform<'a>(
doc: &'a Document,
name: impl Into<IrTypeName<'a>>,
schema: &'a Schema,
) -> IrType<'a> {
IrTransformer::new(doc, name.into(), schema).transform()
}
#[derive(Debug)]
struct IrTransformer<'a> {
doc: &'a Document,
name: IrTypeName<'a>,
schema: &'a Schema,
}
impl<'a> IrTransformer<'a> {
fn new(doc: &'a Document, name: IrTypeName<'a>, schema: &'a Schema) -> Self {
Self { doc, name, schema }
}
fn transform(self) -> IrType<'a> {
self.try_tagged()
.or_else(Self::try_untagged)
.or_else(Self::try_enum)
.or_else(Self::try_struct)
.unwrap_or_else(Self::other)
}
fn try_tagged(self) -> Result<IrType<'a>, Self> {
let (Some(one_of), Some(discriminator)) = (&self.schema.one_of, &self.schema.discriminator)
else {
return Err(self);
};
let inverted: BTreeMap<_, Vec<_>> =
discriminator
.mapping
.iter()
.fold(BTreeMap::new(), |mut mapping, (tag, reference)| {
mapping.entry(reference).or_default().push(tag.as_str());
mapping
});
let variants = one_of
.iter()
.filter_map(|schema| match schema {
RefOrSchema::Ref(r) => {
let aliases = inverted.get(&r.path).cloned().unwrap_or_default();
Some(IrTaggedVariant {
name: r.path.as_str(),
ty: IrType::Ref(r.path.as_str()),
aliases,
})
}
RefOrSchema::Schema(_) => None,
})
.filter(|v| !v.aliases.is_empty())
.collect();
let tagged = IrTagged {
description: self.schema.description.as_deref(),
tag: discriminator.property_name.as_str(),
variants,
};
Ok(match self.name {
IrTypeName::Schema(name) => SchemaIrType::Tagged(name, tagged).into(),
IrTypeName::Inline(_path) => todo!(),
})
}
fn try_untagged(self) -> Result<IrType<'a>, Self> {
let Some(one_of) = &self.schema.one_of else {
return Err(self);
};
let variants = one_of
.iter()
.enumerate()
.map(|(index, schema)| (index + 1, schema))
.map(|(index, schema)| match schema {
RefOrSchema::Ref(r) => IrUntaggedVariant::Some(
IrUntaggedVariantNameHint::Index(index),
IrType::Ref(r.path.as_str()),
),
RefOrSchema::Schema(s) if matches!(&*s.ty, [Ty::Null]) => IrUntaggedVariant::Null,
RefOrSchema::Schema(schema) => {
let path = match &self.name {
IrTypeName::Schema(name) => InlineIrTypePath {
root: InlineIrTypePathRoot::Type(name),
segments: vec![InlineIrTypePathSegment::Variant(index)],
},
IrTypeName::Inline(path) => {
let mut path = path.clone();
path.segments.push(InlineIrTypePathSegment::Variant(index));
path
}
};
IrUntaggedVariant::Some(
IrUntaggedVariantNameHint::Index(index),
transform(self.doc, path, schema),
)
}
})
.collect_vec();
Ok(match &*variants {
[] => IrType::Any,
[IrUntaggedVariant::Null] => IrType::Any,
[IrUntaggedVariant::Some(_, ty)] => ty.clone(),
[IrUntaggedVariant::Some(_, ty), IrUntaggedVariant::Null] => {
IrType::Nullable(ty.clone().into())
}
[IrUntaggedVariant::Null, IrUntaggedVariant::Some(_, ty)] => {
IrType::Nullable(ty.clone().into())
}
_ => {
let untagged = IrUntagged {
description: self.schema.description.as_deref(),
variants,
};
match self.name {
IrTypeName::Schema(name) => SchemaIrType::Untagged(name, untagged).into(),
IrTypeName::Inline(path) => InlineIrType::Untagged(path, untagged).into(),
}
}
})
}
fn try_enum(self) -> Result<IrType<'a>, Self> {
let Some(values) = &self.schema.variants else {
return Err(self);
};
let variants = values
.iter()
.filter_map(|value| value.as_str())
.map(IrEnumVariant::String)
.collect();
let ty = IrEnum {
description: self.schema.description.as_deref(),
variants,
};
Ok(match self.name {
IrTypeName::Schema(name) => SchemaIrType::Enum(name, ty).into(),
IrTypeName::Inline(path) => InlineIrType::Enum(path, ty).into(),
})
}
fn try_struct(self) -> Result<IrType<'a>, Self> {
if self
.schema
.additional_properties
.as_ref()
.is_some_and(|additional| {
matches!(
additional,
AdditionalProperties::RefOrSchema(_) | AdditionalProperties::Bool(true)
)
})
{
return Err(self);
}
if self.schema.properties.is_none() && self.schema.all_of.is_none() {
return Err(self);
}
let all = all_fields(self.doc, self.schema);
let fields = all
.map(|(field_name, field)| {
let info = field.info();
let ty = match info.schema {
RefOrSchema::Ref(reference) => IrType::Ref(reference.path.as_str()),
RefOrSchema::Schema(schema) => {
let path = match &self.name {
IrTypeName::Schema(name) => InlineIrTypePath {
root: InlineIrTypePathRoot::Type(name),
segments: vec![InlineIrTypePathSegment::Field(field_name)],
},
IrTypeName::Inline(path) => {
let mut path = path.clone();
path.segments
.push(InlineIrTypePathSegment::Field(field_name));
path
}
};
transform(self.doc, path, schema)
}
};
let description = match info.schema {
RefOrSchema::Schema(schema) => schema.description.as_deref(),
RefOrSchema::Ref(r) => self
.doc
.components
.as_ref()
.and_then(|c| c.schemas.get(r.path.as_str()))
.and_then(|schema| schema.description.as_deref()),
};
let nullable = match info.schema {
RefOrSchema::Schema(schema) if schema.nullable => true,
RefOrSchema::Ref(r) => {
if let Some(components) = &self.doc.components
&& let Some(schema) = components.schemas.get(r.path.as_str())
&& schema.nullable
{
true
} else {
false
}
}
_ => false,
};
let ty = if nullable {
IrType::Nullable(ty.into())
} else {
ty
};
IrStructField {
name: field_name,
ty,
required: info.required,
description,
inherited: matches!(field, IrSchemaField::Inherited(_)),
discriminator: info.discriminator,
}
})
.collect_vec();
let ty = IrStruct {
description: self.schema.description.as_deref(),
fields,
};
Ok(match self.name {
IrTypeName::Schema(name) => SchemaIrType::Struct(name, ty).into(),
IrTypeName::Inline(path) => InlineIrType::Struct(path, ty).into(),
})
}
fn other(self) -> IrType<'a> {
let variants = self
.schema
.ty
.iter()
.map(|&ty| match (ty, self.schema.format) {
(Ty::String, Some(Format::DateTime)) => PrimitiveIrType::DateTime.into(),
(Ty::String, Some(Format::Date)) => PrimitiveIrType::Date.into(),
(Ty::String, Some(Format::Uri)) => PrimitiveIrType::Url.into(),
(Ty::String, Some(Format::Uuid)) => PrimitiveIrType::Uuid.into(),
(Ty::String, Some(Format::Byte) | Some(Format::Binary)) => {
PrimitiveIrType::Bytes.into()
}
(Ty::String, _) => PrimitiveIrType::String.into(),
(Ty::Integer, Some(Format::Int64)) => PrimitiveIrType::I64.into(),
(Ty::Integer, Some(Format::Int32) | _) => PrimitiveIrType::I32.into(),
(Ty::Number, Some(Format::Float)) => PrimitiveIrType::F32.into(),
(Ty::Number, Some(Format::Double) | _) => PrimitiveIrType::F64.into(),
(Ty::Boolean, _) => PrimitiveIrType::Bool.into(),
(Ty::Array, _) => {
let items = match &self.schema.items {
Some(RefOrSchema::Ref(r)) => IrType::Ref(r.path.as_str()),
Some(RefOrSchema::Schema(schema)) => {
let path = match &self.name {
IrTypeName::Schema(name) => InlineIrTypePath {
root: InlineIrTypePathRoot::Type(name),
segments: vec![InlineIrTypePathSegment::ArrayItem],
},
IrTypeName::Inline(path) => {
let mut path = path.clone();
path.segments.push(InlineIrTypePathSegment::ArrayItem);
path
}
};
transform(self.doc, path, schema)
}
None => IrType::Any,
};
let ty = IrType::Array(items.into());
IrUntaggedVariant::Some(IrUntaggedVariantNameHint::Array, ty)
}
(Ty::Object, _) => {
let ty = match &self.schema.additional_properties {
Some(AdditionalProperties::RefOrSchema(RefOrSchema::Ref(r))) => {
IrType::Map(IrType::Ref(r.path.as_str()).into())
}
Some(AdditionalProperties::RefOrSchema(RefOrSchema::Schema(schema))) => {
let path = match &self.name {
IrTypeName::Schema(name) => InlineIrTypePath {
root: InlineIrTypePathRoot::Type(name),
segments: vec![InlineIrTypePathSegment::MapValue],
},
IrTypeName::Inline(path) => {
let mut path = path.clone();
path.segments.push(InlineIrTypePathSegment::MapValue);
path
}
};
IrType::Map(transform(self.doc, path, schema).into())
}
Some(AdditionalProperties::Bool(true)) => IrType::Map(IrType::Any.into()),
_ => IrType::Any,
};
IrUntaggedVariant::Some(IrUntaggedVariantNameHint::Map, ty)
}
(Ty::Null, _) => IrUntaggedVariant::Null,
})
.collect_vec();
match &*variants {
[] => IrType::Any,
[IrUntaggedVariant::Null] => IrType::Any,
[IrUntaggedVariant::Some(_, ty)] => ty.clone(),
[IrUntaggedVariant::Some(_, ty), IrUntaggedVariant::Null] => {
IrType::Nullable(ty.clone().into())
}
[IrUntaggedVariant::Null, IrUntaggedVariant::Some(_, ty)] => {
IrType::Nullable(ty.clone().into())
}
_ => {
let untagged = IrUntagged {
description: self.schema.description.as_deref(),
variants,
};
match self.name {
IrTypeName::Schema(name) => SchemaIrType::Untagged(name, untagged).into(),
IrTypeName::Inline(path) => InlineIrType::Untagged(path, untagged).into(),
}
}
}
}
}
+270
View File
@@ -0,0 +1,270 @@
//! Language-agnostic intermediate representation types.
use crate::parse::{Method, path::PathSegment};
use super::visitor::{Visitable, Visitor};
/// A schema type ready for code generation.
#[derive(Clone, Debug)]
pub enum IrType<'a> {
/// A primitive type.
Primitive(PrimitiveIrType),
/// An array of items.
Array(Box<IrType<'a>>),
/// A map with string keys.
Map(Box<IrType<'a>>),
/// A nullable type.
Nullable(Box<IrType<'a>>),
/// A reference to another named schema type.
Ref(&'a str),
/// A named schema type.
Schema(SchemaIrType<'a>),
/// An inline type defined within a schema.
Inline(InlineIrType<'a>),
/// Any JSON value.
Any,
}
impl IrType<'_> {
/// Visits the inner types within this schema type.
#[inline]
pub fn visit<'a, F: Visitable<'a>>(&'a self) -> impl Iterator<Item = F> {
Visitor::new(self).filter_map(F::accept)
}
}
impl From<PrimitiveIrType> for IrType<'_> {
fn from(ty: PrimitiveIrType) -> Self {
Self::Primitive(ty)
}
}
impl<'a> From<SchemaIrType<'a>> for IrType<'a> {
fn from(ty: SchemaIrType<'a>) -> Self {
Self::Schema(ty)
}
}
impl<'a> From<InlineIrType<'a>> for IrType<'a> {
fn from(ty: InlineIrType<'a>) -> Self {
Self::Inline(ty)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum PrimitiveIrType {
String,
I32,
I64,
F32,
F64,
Bool,
DateTime,
Date,
Url,
Uuid,
Bytes,
}
/// A named schema type.
#[derive(Clone, Debug)]
pub enum SchemaIrType<'a> {
/// An enum with named variants.
Enum(&'a str, IrEnum<'a>),
/// A struct with fields.
Struct(&'a str, IrStruct<'a>),
/// A tagged union.
Tagged(&'a str, IrTagged<'a>),
/// An untagged union.
Untagged(&'a str, IrUntagged<'a>),
}
impl SchemaIrType<'_> {
/// Visits the inner types within this named schema type.
#[inline]
pub fn visit<'a, F: Visitable<'a>>(&'a self) -> impl Iterator<Item = F> {
Visitor::for_schema_ty(self).filter_map(F::accept)
}
}
impl<'a> SchemaIrType<'a> {
#[inline]
pub fn name(&self) -> &'a str {
let (Self::Enum(name, _)
| Self::Struct(name, _)
| Self::Tagged(name, _)
| Self::Untagged(name, _)) = self;
name
}
}
/// An inline schema type.
#[derive(Clone, Debug)]
pub enum InlineIrType<'a> {
Enum(InlineIrTypePath<'a>, IrEnum<'a>),
Struct(InlineIrTypePath<'a>, IrStruct<'a>),
Untagged(InlineIrTypePath<'a>, IrUntagged<'a>),
}
impl<'a> InlineIrType<'a> {
#[inline]
pub fn path(&self) -> &InlineIrTypePath<'a> {
let (Self::Enum(path, _) | Self::Struct(path, _) | Self::Untagged(path, _)) = self;
path
}
}
/// A path to an inline type.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InlineIrTypePath<'a> {
pub root: InlineIrTypePathRoot<'a>,
pub segments: Vec<InlineIrTypePathSegment<'a>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InlineIrTypePathRoot<'a> {
Resource(&'a str),
Type(&'a str),
}
/// A segment of an inline type path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InlineIrTypePathSegment<'a> {
Operation(&'a str),
Parameter(&'a str),
Request,
Response,
Field(&'a str),
MapValue,
ArrayItem,
Variant(usize),
}
/// An enum type.
#[derive(Clone, Debug)]
pub struct IrEnum<'a> {
pub description: Option<&'a str>,
pub variants: Vec<IrEnumVariant<'a>>,
}
/// A variant of an enum.
#[derive(Clone, Debug)]
pub enum IrEnumVariant<'a> {
String(&'a str),
}
/// A struct, created from a schema with named properties.
#[derive(Clone, Debug)]
pub struct IrStruct<'a> {
pub description: Option<&'a str>,
pub fields: Vec<IrStructField<'a>>,
}
/// A field in a struct.
#[derive(Clone, Debug)]
pub struct IrStructField<'a> {
pub name: &'a str,
pub ty: IrType<'a>,
pub required: bool,
pub description: Option<&'a str>,
pub inherited: bool,
pub discriminator: bool,
}
/// A tagged union, created from a `oneOf` schema
/// with an explicit `discriminator`.
#[derive(Clone, Debug)]
pub struct IrTagged<'a> {
pub description: Option<&'a str>,
pub tag: &'a str,
pub variants: Vec<IrTaggedVariant<'a>>,
}
/// A variant of a tagged union.
#[derive(Clone, Debug)]
pub struct IrTaggedVariant<'a> {
pub name: &'a str,
pub aliases: Vec<&'a str>,
pub ty: IrType<'a>,
}
/// An untagged union, created from a `oneOf` schema
/// without a discriminator, or an OpenAPI 3.1 schema
/// with multiple types in its `type` field.
#[derive(Debug, Clone)]
pub struct IrUntagged<'a> {
pub description: Option<&'a str>,
pub variants: Vec<IrUntaggedVariant<'a>>,
}
/// A hint that's used to generate a more descriptive name
/// for an untagged union variant.
#[derive(Clone, Copy, Debug)]
pub enum IrUntaggedVariantNameHint {
Primitive(PrimitiveIrType),
Array,
Map,
Index(usize),
}
/// A variant of an untagged union.
#[derive(Debug, Clone)]
pub enum IrUntaggedVariant<'a> {
Some(IrUntaggedVariantNameHint, IrType<'a>),
Null,
}
impl From<PrimitiveIrType> for IrUntaggedVariant<'_> {
fn from(ty: PrimitiveIrType) -> Self {
Self::Some(IrUntaggedVariantNameHint::Primitive(ty), ty.into())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IrTypeName<'a> {
Schema(&'a str),
Inline(InlineIrTypePath<'a>),
}
impl<'a> From<InlineIrTypePath<'a>> for IrTypeName<'a> {
fn from(path: InlineIrTypePath<'a>) -> Self {
Self::Inline(path)
}
}
#[derive(Clone, Debug)]
pub struct IrOperation<'a> {
pub resource: &'a str,
pub id: &'a str,
pub method: Method,
pub path: Vec<PathSegment<'a>>,
pub description: Option<&'a str>,
pub params: Vec<IrParameter<'a>>,
pub request: Option<IrRequest<'a>>,
pub response: Option<IrResponse<'a>>,
}
#[derive(Clone, Debug)]
pub enum IrResponse<'a> {
Json(IrType<'a>),
}
#[derive(Clone, Debug)]
pub enum IrRequest<'a> {
Json(IrType<'a>),
Multipart,
}
#[derive(Clone, Debug)]
pub enum IrParameter<'a> {
Path(IrParameterInfo<'a>),
Query(IrParameterInfo<'a>),
}
#[derive(Clone, Debug)]
pub struct IrParameterInfo<'a> {
pub name: &'a str,
pub ty: IrType<'a>,
pub required: bool,
pub description: Option<&'a str>,
}
+156
View File
@@ -0,0 +1,156 @@
use super::types::{InlineIrType, IrType, IrUntaggedVariant, PrimitiveIrType, SchemaIrType};
/// An inner type within a schema.
pub trait Visitable<'a>: Sized {
fn accept(inner: InnerIrType<'a>) -> Option<Self>;
}
/// An inner reference to another schema.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct InnerRef<'a>(&'a str);
impl<'a> InnerRef<'a> {
#[inline]
pub fn name(self) -> &'a str {
self.0
}
}
impl<'a> Visitable<'a> for InnerRef<'a> {
#[inline]
fn accept(inner: InnerIrType<'a>) -> Option<Self> {
match inner {
InnerIrType::Ref(name) => Some(Self(name)),
_ => None,
}
}
}
/// An inner leaf type that doesn't reference or contain nested schemas.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum InnerLeaf {
Any,
Primitive(PrimitiveIrType),
}
impl<'a> Visitable<'a> for InnerLeaf {
#[inline]
fn accept(inner: InnerIrType<'a>) -> Option<Self> {
match inner {
InnerIrType::Any => Some(InnerLeaf::Any),
InnerIrType::Primitive(ty) => Some(InnerLeaf::Primitive(ty)),
_ => None,
}
}
}
impl<'a> Visitable<'a> for &InlineIrType<'a> {
#[inline]
fn accept(inner: InnerIrType<'a>) -> Option<Self> {
match inner {
InnerIrType::Inline(ty) => Some(ty),
_ => None,
}
}
}
#[derive(Debug)]
pub struct Visitor<'a> {
stack: Vec<&'a IrType<'a>>,
}
impl<'a> Visitor<'a> {
#[inline]
pub fn new(root: &'a IrType<'a>) -> Self {
Self { stack: vec![root] }
}
#[inline]
pub fn for_schema_ty(root: &'a SchemaIrType<'a>) -> Self {
let stack = match root {
SchemaIrType::Struct(_, ty) => ty.fields.iter().map(|field| &field.ty).rev().collect(),
SchemaIrType::Untagged(_, ty) => ty
.variants
.iter()
.filter_map(|variant| match variant {
IrUntaggedVariant::Some(_, ty) => Some(ty),
_ => None,
})
.rev()
.collect(),
SchemaIrType::Tagged(_, ty) => ty.variants.iter().map(|name| &name.ty).rev().collect(),
SchemaIrType::Enum(..) => vec![],
};
Self { stack }
}
}
impl<'a> Iterator for Visitor<'a> {
type Item = InnerIrType<'a>;
fn next(&mut self) -> Option<Self::Item> {
while let Some(top) = self.stack.pop() {
match top {
IrType::Array(ty) => {
self.stack.push(ty.as_ref());
}
IrType::Map(ty) => {
self.stack.push(ty.as_ref());
}
IrType::Nullable(ty) => {
self.stack.push(ty.as_ref());
}
IrType::Schema(SchemaIrType::Struct(_, ty)) => {
self.stack
.extend(ty.fields.iter().map(|field| &field.ty).rev());
}
IrType::Schema(SchemaIrType::Untagged(_, ty)) => {
self.stack.extend(
ty.variants
.iter()
.filter_map(|variant| match variant {
IrUntaggedVariant::Some(_, ty) => Some(ty),
_ => None,
})
.rev(),
);
}
IrType::Schema(SchemaIrType::Tagged(_, ty)) => {
self.stack
.extend(ty.variants.iter().map(|name| &name.ty).rev());
}
IrType::Schema(SchemaIrType::Enum(..)) => continue,
IrType::Any => return Some(InnerIrType::Any),
&IrType::Primitive(ty) => return Some(InnerIrType::Primitive(ty)),
IrType::Inline(ty) => {
match ty {
InlineIrType::Enum(..) => {}
InlineIrType::Untagged(_, ty) => self.stack.extend(
ty.variants
.iter()
.filter_map(|variant| match variant {
IrUntaggedVariant::Some(_, ty) => Some(ty),
_ => None,
})
.rev(),
),
InlineIrType::Struct(_, ty) => self
.stack
.extend(ty.fields.iter().map(|field| &field.ty).rev()),
}
return Some(InnerIrType::Inline(ty));
}
&IrType::Ref(name) => return Some(InnerIrType::Ref(name)),
}
}
None
}
}
#[derive(Clone, Copy, Debug)]
pub enum InnerIrType<'a> {
Any,
Primitive(PrimitiveIrType),
Inline(&'a InlineIrType<'a>),
Ref(&'a str),
}
+5
View File
@@ -0,0 +1,5 @@
pub mod codegen;
pub mod config;
pub mod error;
pub mod ir;
pub mod parse;
+101
View File
@@ -0,0 +1,101 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use miette::{Context, IntoDiagnostic, Result};
use ploidy::codegen::{rust, write_to_disk};
use ploidy::config::{Codegen, CodegenLanguage, Command, Main};
use ploidy::ir::IrSpec;
use ploidy::parse::Document;
fn main() -> Result<()> {
let Ok(main) = Main::parse().map_err(|err| err.exit());
match main.command {
Command::Codegen(Codegen {
input,
output,
language: CodegenLanguage::Rust(config),
}) => {
let source = std::fs::read_to_string(&input)
.into_diagnostic()
.with_context(|| format!("Failed to read `{}`", input.display()))?;
let doc = Document::from_yaml(&source)
.into_diagnostic()
.context("Failed to parse OpenAPI document")?;
println!("OpenAPI: {} (version {})", doc.info.title, doc.info.version);
let spec = IrSpec::from_doc(&doc).into_diagnostic()?;
let name = config
.package
.name
.as_deref()
.map(Cow::Borrowed)
.unwrap_or_else(|| output.to_string_lossy());
let context = rust::CodegenContext::new(
&name,
config.package.version_or_default(),
config.package.license_or_default(),
config.package.description.as_deref(),
&spec,
);
println!("Writing generated code to `{}`...", output.display());
println!("Generating `Cargo.toml`...");
write_to_disk(&output, rust::CargoManifest::new(&context))?;
println!("Generating `lib.rs`...");
write_to_disk(&output, rust::CodegenLibrary)?;
println!("Generating `error.rs`...");
write_to_disk(&output, rust::CodegenErrorModule)?;
println!("Generating `absent.rs`...");
write_to_disk(&output, rust::CodegenAbsentModule)?;
println!("Generating `date_time`.rs...");
write_to_disk(&output, rust::CodegenDateTimeModule)?;
println!("Generating {} types...", context.spec.schemas().count());
rust::write_types_to_disk(&output, &context)?;
let counts = context.spec.operations().fold(
BTreeMap::<&str, usize>::new(),
|mut counts, view| {
*counts.entry(view.op().resource).or_default() += 1;
counts
},
);
println!(
"Generating {} client methods for {} resources...",
counts.values().copied().sum::<usize>(),
counts.keys().count(),
);
rust::write_client_to_disk(&output, &context)?;
println!("Generation complete");
if config.check {
println!("Running `cargo check`...");
let status = std::process::Command::new("cargo")
.arg("check")
.arg("--all-targets")
.arg("--features")
.arg("full")
.current_dir(&output)
.status()
.into_diagnostic()?;
if !status.success() {
miette::bail!("`cargo check` exited with status {status}");
}
}
}
}
Ok(())
}
+5
View File
@@ -0,0 +1,5 @@
pub mod path;
mod types;
pub use types::*;
+276
View File
@@ -0,0 +1,276 @@
use std::borrow::Cow;
use miette::SourceSpan;
use winnow::{
Parser,
combinator::eof,
error::{ContextError, ParseError},
};
/// Parses a path template, like `/v1/pets/{petId}/toy`.
///
/// The grammar for path templating is adapted directly from
/// https://spec.openapis.org/oas/v3.2.0.html#x4-8-2-path-templating.
pub fn parse<'a>(input: &'a str) -> Result<Vec<PathSegment<'a>>, BadPath> {
(self::parser::template, eof)
.map(|(segments, _)| segments)
.parse(input)
.map_err(BadPath::from_parse_error)
}
/// A slash-delimited path segment that contains zero or more
/// template fragments.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PathSegment<'input>(Vec<PathFragment<'input>>);
impl<'input> PathSegment<'input> {
pub fn fragments(&self) -> &[PathFragment<'input>] {
&self.0
}
}
/// A fragment within a path segment.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PathFragment<'input> {
/// Literal text.
Literal(Cow<'input, str>),
/// Template parameter name.
Param(&'input str),
}
mod parser {
use super::*;
use winnow::{
Parser,
combinator::{alt, delimited, repeat},
token::take_while,
};
pub fn template<'a>(input: &mut &'a str) -> winnow::Result<Vec<PathSegment<'a>>> {
alt((
('/', segment, template)
.map(|(_, head, tail)| std::iter::once(head).chain(tail).collect()),
('/', segment).map(|(_, segment)| vec![segment]),
'/'.map(|_| vec![PathSegment::default()]),
))
.parse_next(input)
}
fn segment<'a>(input: &mut &'a str) -> winnow::Result<PathSegment<'a>> {
repeat(1.., fragment).map(PathSegment).parse_next(input)
}
fn fragment<'a>(input: &mut &'a str) -> winnow::Result<PathFragment<'a>> {
alt((param, literal)).parse_next(input)
}
pub fn param<'a>(input: &mut &'a str) -> winnow::Result<PathFragment<'a>> {
delimited('{', take_while(1.., |c| c != '{' && c != '}'), '}')
.map(PathFragment::Param)
.parse_next(input)
}
pub fn literal<'a>(input: &mut &'a str) -> winnow::Result<PathFragment<'a>> {
take_while(1.., |c| {
matches!(c,
'A'..='Z' | 'a'..='z' | '0'..='9' |
'-' | '.' | '_' | '~' | ':' | '@' |
'!' | '$' | '&' | '\'' | '(' | ')' |
'*' | '+' | ',' | ';' | '=' | '%'
)
})
.verify_map(|text| {
percent_encoding::percent_decode_str(text)
.decode_utf8()
.ok()
.map(PathFragment::Literal)
})
.parse_next(input)
}
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error("invalid URL path template")]
pub struct BadPath {
#[source_code]
code: String,
#[label]
span: SourceSpan,
}
impl BadPath {
fn from_parse_error(error: ParseError<&str, ContextError>) -> Self {
let input = *error.input();
Self {
code: input.to_owned(),
span: error.char_span().into(),
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_root_path() {
let result = parse("/").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].fragments(), &[]);
}
#[test]
fn test_simple_literal() {
let result = parse("/users").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].fragments(),
&[PathFragment::Literal("users".into())]
);
}
#[test]
fn test_trailing_slash() {
let result = parse("/users/").unwrap();
assert_eq!(result.len(), 2);
assert_eq!(
result[0].fragments(),
&[PathFragment::Literal("users".into())]
);
assert_eq!(result[1].fragments(), &[]);
}
#[test]
fn test_simple_template() {
let result = parse("/users/{userId}").unwrap();
assert_eq!(result.len(), 2);
assert_eq!(
result[0].fragments(),
&[PathFragment::Literal("users".into())]
);
assert_eq!(
result[1].fragments(),
&[PathFragment::Param("userId".into())]
);
}
#[test]
fn test_nested_path() {
let result = parse("/api/v1/resources/{resourceId}").unwrap();
assert_eq!(result.len(), 4);
assert_eq!(
result[0].fragments(),
&[PathFragment::Literal("api".into())]
);
assert_eq!(result[1].fragments(), &[PathFragment::Literal("v1".into())]);
assert_eq!(
result[2].fragments(),
&[PathFragment::Literal("resources".into())]
);
assert_eq!(result[3].fragments(), &[PathFragment::Param("resourceId")]);
}
#[test]
fn test_multiple_templates() {
let result = parse("/users/{userId}/posts/{postId}").unwrap();
assert_eq!(result.len(), 4);
assert_eq!(
result[0].fragments(),
&[PathFragment::Literal("users".into())]
);
assert_eq!(result[1].fragments(), &[PathFragment::Param("userId")]);
assert_eq!(
result[2].fragments(),
&[PathFragment::Literal("posts".into())]
);
assert_eq!(result[3].fragments(), &[PathFragment::Param("postId")]);
}
#[test]
fn test_literal_with_extension() {
let result =
parse("/v1/storage/workspace/{workspace}/documents/download/{documentId}.pdf").unwrap();
assert_eq!(result.len(), 7);
assert_eq!(result[0].fragments(), &[PathFragment::Literal("v1".into())]);
assert_eq!(
result[1].fragments(),
&[PathFragment::Literal("storage".into())]
);
assert_eq!(
result[2].fragments(),
&[PathFragment::Literal("workspace".into())]
);
assert_eq!(result[3].fragments(), &[PathFragment::Param("workspace")]);
assert_eq!(
result[4].fragments(),
&[PathFragment::Literal("documents".into())]
);
assert_eq!(
result[5].fragments(),
&[PathFragment::Literal("download".into())]
);
assert_eq!(
result[6].fragments(),
&[
PathFragment::Param("documentId"),
PathFragment::Literal(".pdf".into())
]
);
}
#[test]
fn test_mixed_literal_and_param() {
let result =
parse("/v1/storage/workspace/{workspace}/documents/download/report-{documentId}.pdf")
.unwrap();
assert_eq!(result.len(), 7);
assert_eq!(result[0].fragments(), &[PathFragment::Literal("v1".into())]);
assert_eq!(
result[1].fragments(),
&[PathFragment::Literal("storage".into())]
);
assert_eq!(
result[2].fragments(),
&[PathFragment::Literal("workspace".into())]
);
assert_eq!(result[3].fragments(), &[PathFragment::Param("workspace")]);
assert_eq!(
result[4].fragments(),
&[PathFragment::Literal("documents".into())]
);
assert_eq!(
result[5].fragments(),
&[PathFragment::Literal("download".into())]
);
assert_eq!(
result[6].fragments(),
&[
PathFragment::Literal("report-".into()),
PathFragment::Param("documentId"),
PathFragment::Literal(".pdf".into())
]
);
}
#[test]
fn test_double_slash() {
// Empty path segments aren't allowed.
assert!(parse("/users//a").is_err());
}
#[test]
fn test_invalid_chars_in_template() {
// Parameter names can contain any character except for
// `{` and `}`, per the `template-expression-param-name` terminal.
assert!(parse("/users/{user/{id}}").is_err());
}
}
+328
View File
@@ -0,0 +1,328 @@
use std::str::FromStr;
use indexmap::IndexMap;
use itertools::Itertools;
use serde::{Deserialize, Deserializer};
use crate::error::SerdeError;
/// An OpenAPI document.
#[derive(Debug, Deserialize)]
pub struct Document {
pub openapi: String,
pub info: Info,
#[serde(default)]
pub paths: IndexMap<String, PathItem>,
#[serde(default)]
pub components: Option<Components>,
}
impl Document {
/// Parse an OpenAPI document from a YAML or JSON string.
pub fn from_yaml(yaml: &str) -> Result<Self, SerdeError> {
let deserializer = serde_yaml::Deserializer::from_str(yaml);
let result = serde_path_to_error::deserialize(deserializer)?;
Ok(result)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Info {
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub version: String,
}
/// Operation definitions for a single path.
#[derive(Debug, Deserialize)]
pub struct PathItem {
#[serde(default)]
pub get: Option<Operation>,
#[serde(default)]
pub post: Option<Operation>,
#[serde(default)]
pub put: Option<Operation>,
#[serde(default)]
pub delete: Option<Operation>,
#[serde(default)]
pub patch: Option<Operation>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Method {
Get,
Post,
Put,
Delete,
Patch,
}
impl PathItem {
/// Yields all operations and their HTTP methods.
pub fn operations(&self) -> impl Iterator<Item = (Method, &Operation)> {
[
(Method::Get, self.get.as_ref()),
(Method::Post, self.post.as_ref()),
(Method::Put, self.put.as_ref()),
(Method::Delete, self.delete.as_ref()),
(Method::Patch, self.patch.as_ref()),
]
.into_iter()
.filter_map(|(method, op)| op.map(|o| (method, o)))
}
}
/// An HTTP operation.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Operation {
#[serde(default)]
pub description: Option<String>,
pub operation_id: Option<String>,
#[serde(default)]
pub parameters: Vec<Parameter>,
#[serde(default)]
pub request_body: Option<RequestBody>,
#[serde(default)]
pub responses: IndexMap<String, Response>,
#[serde(flatten)]
pub extensions: IndexMap<String, serde_json::Value>,
}
impl Operation {
pub fn extension(&self, name: &str) -> Option<&str> {
self.extensions.get(name)?.as_str()
}
}
/// A path, query, header, or cookie parameter.
#[derive(Debug, Deserialize)]
pub struct Parameter {
pub name: String,
#[serde(rename = "in")]
pub location: ParameterLocation,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub schema: Option<RefOrSchema>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParameterLocation {
Path,
Query,
Header,
Cookie,
}
/// Request body definition.
#[derive(Debug, Deserialize)]
pub struct RequestBody {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub content: IndexMap<String, MediaType>,
}
/// Response definition.
#[derive(Debug, Deserialize)]
pub struct Response {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub content: Option<IndexMap<String, MediaType>>,
}
/// Media type content.
#[derive(Debug, Deserialize)]
pub struct MediaType {
#[serde(default)]
pub schema: Option<RefOrSchema>,
}
/// Components section containing reusable schemas.
#[derive(Debug, Deserialize)]
pub struct Components {
#[serde(default)]
pub schemas: IndexMap<String, Schema>,
}
/// Either a reference or a schema definition.
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum RefOrSchema {
Ref(Ref),
Schema(Box<Schema>),
}
/// A reference to another schema.
#[derive(Debug, Clone, Deserialize)]
pub struct Ref {
#[serde(rename = "$ref")]
pub path: SchemaRefPath,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Ty {
String,
Integer,
Number,
Boolean,
Array,
Object,
Null,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Format {
#[serde(rename = "date-time")]
DateTime,
Date,
Uri,
Uuid,
Byte,
Binary,
Int32,
Int64,
Float,
Double,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum AdditionalProperties {
Bool(bool),
RefOrSchema(RefOrSchema),
}
/// An OpenAPI schema definition.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Schema {
#[serde(rename = "type", default, deserialize_with = "deserialize_type")]
pub ty: Vec<Ty>,
#[serde(default)]
pub format: Option<Format>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub nullable: bool,
// Object properties.
#[serde(default)]
pub properties: Option<IndexMap<String, RefOrSchema>>,
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub additional_properties: Option<AdditionalProperties>,
// Array items.
#[serde(default)]
pub items: Option<RefOrSchema>,
// Enum variants.
#[serde(rename = "enum", default)]
pub variants: Option<Vec<serde_json::Value>>,
// Composition.
#[serde(default)]
pub all_of: Option<Vec<RefOrSchema>>,
#[serde(default)]
pub one_of: Option<Vec<RefOrSchema>>,
#[serde(default)]
pub any_of: Option<Vec<RefOrSchema>>,
#[serde(default)]
pub discriminator: Option<Discriminator>,
}
/// A discriminator for a polymorphic type.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Discriminator {
pub property_name: String,
#[serde(default)]
pub mapping: IndexMap<String, SchemaRefPath>,
}
/// The path of a schema reference.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SchemaRefPath(String);
impl SchemaRefPath {
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl FromStr for SchemaRefPath {
type Err = BadSchemaRefPath;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// A makeshift JSON Schema reference parser (<URI> # <JSON-Pointer>)
// that only understands references to keys under `/components/schemas`
// in the current document.
let Some(pointer) = s
.trim_matches(|c| c <= ' ')
.strip_prefix('#')
.map(|rest| &rest[..rest.find(['\t', '\n', '\r']).unwrap_or(rest.len())])
else {
return Err(BadSchemaRefPath);
};
if !pointer.starts_with('/') {
return Err(BadSchemaRefPath);
}
let mut parts = pointer.split('/').skip(1);
let Some(["components", "schemas", name]) = parts.next_array() else {
return Err(BadSchemaRefPath);
};
if parts.next().is_some() {
return Err(BadSchemaRefPath);
}
Ok(Self(name.replace("~1", "/").replace("~0", "~")))
}
}
impl<'de> Deserialize<'de> for SchemaRefPath {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = SchemaRefPath;
fn expecting(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
f.write_str("a schema reference")
}
fn visit_str<E: ::serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
s.parse().map_err(E::custom)
}
}
deserializer.deserialize_str(Visitor)
}
}
fn deserialize_type<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Ty>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum TypesOr {
/// An OpenAPI 3.1-style `type` array.
Types(Vec<Ty>),
/// A single `type`.
Type(Ty),
}
Ok(match TypesOr::deserialize(deserializer)? {
TypesOr::Types(types) => types,
TypesOr::Type(ty) => vec![ty],
})
}
#[derive(Debug, thiserror::Error)]
#[error("only `#/components/schemas/{{name}}` references are supported")]
pub struct BadSchemaRefPath;