From 8b406a5dd62f13a21d27f3d420b2a7276df34e6a Mon Sep 17 00:00:00 2001 From: Mikkel Denker Date: Tue, 19 Nov 2024 09:56:58 +0100 Subject: [PATCH 1/2] Update openapi spec from 3.0.x to 3.1.x 3.1 is unfortunately not backwards compatible with 3.0, so we have to change the openapi type from 'openapiv3' which only supports 3.0 to the one used internally in utoipa. This of course results in a big internal change in abeye, but I think the functionality is roughly the same. The generated bindings at least fully type checks in stract. --- Cargo.lock | 36 ++++-- Cargo.toml | 2 +- src/lib.rs | 322 +++++++++++++++++++++++++--------------------------- src/main.rs | 4 +- src/ts.rs | 61 +++++----- 5 files changed, 217 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ced005..c696dcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,7 +12,6 @@ dependencies = [ "heck", "indent_write", "itertools", - "openapiv3", "pluralizer", "reqwest", "salsa-2022", @@ -21,6 +20,7 @@ dependencies = [ "tracing", "tracing-error", "tracing-subscriber", + "utoipa", ] [[package]] @@ -838,17 +838,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" -[[package]] -name = "openapiv3" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e56d5c441965b6425165b7e3223cc933ca469834f4a8b4786817a1f9dc4f13" -dependencies = [ - "indexmap 2.0.0", - "serde", - "serde_json", -] - [[package]] name = "openssl" version = "0.10.57" @@ -1484,6 +1473,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "utoipa" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514a48569e4e21c86d0b84b5612b5e73c0b2cf09db63260134ba426d4e8ea714" +dependencies = [ + "indexmap 2.0.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5629efe65599d0ccd5d493688cbf6e03aa7c1da07fe59ff97cf5977ed0637f66" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.37", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 51e513d..87547b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ color-eyre = "0.6.2" heck = "0.4.1" indent_write = "2.2.0" itertools = "0.11.0" -openapiv3 = "1.0.3" +utoipa = { version = "5.2.0", features = ["debug"] } pluralizer = "0.4.0" reqwest = { version = "0.11.20", features = ["blocking", "json"] } salsa = { git = "https://github.com/salsa-rs/salsa.git", package = "salsa-2022" } diff --git a/src/lib.rs b/src/lib.rs index 4dc7c96..d7914bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,10 +5,10 @@ use camino::Utf8PathBuf; pub use db::Database; pub use ts::generate_ts; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use itertools::Itertools; -use openapiv3 as oapi; +use utoipa::openapi as oapi; #[salsa::jar(db = Db)] pub struct Jar( @@ -33,11 +33,12 @@ pub struct Config { #[salsa::input] pub struct InputApi { #[return_ref] - pub api: oapi::OpenAPI, + pub api: oapi::OpenApi, pub config: Config, } #[salsa::interned] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] struct Type { kind: TypeKind, } @@ -54,6 +55,7 @@ enum TypeKind { Ident(String), String, Boolean, + Null, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -61,14 +63,6 @@ struct Property { ty: Type, optional: bool, } -impl Property { - fn required(ty: Type) -> Self { - Property { - ty, - optional: false, - } - } -} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RequestKind { @@ -107,11 +101,8 @@ impl Schema { fn from_oapi(db: &dyn crate::Db, schema: oapi::Schema) -> Schema { Schema::new(db, OapiSchema { schema }) } - fn kind(self, db: &dyn crate::Db) -> &oapi::SchemaKind { - &self.schema(db).schema.schema_kind - } - fn data(self, db: &dyn crate::Db) -> &oapi::SchemaData { - &self.schema(db).schema.schema_data + fn inner(self, db: &dyn crate::Db) -> &oapi::Schema { + &self.schema(db).schema } } @@ -138,33 +129,29 @@ impl Type { } } -fn resolve_schema( - db: &dyn crate::Db, - api: InputApi, - schema: &oapi::ReferenceOr, -) -> Schema { +fn resolve_schema(db: &dyn crate::Db, api: InputApi, schema: &oapi::RefOr) -> Schema { match schema { - oapi::ReferenceOr::Reference { reference } => { - schema_by_name(db, api, reference.clone()).unwrap() + oapi::RefOr::Ref(reference) => { + schema_by_name(db, api, reference.ref_location.clone()).unwrap() } - oapi::ReferenceOr::Item(schema) => Schema::from_oapi(db, schema.clone()), + oapi::RefOr::T(schema) => Schema::from_oapi(db, schema.clone()), } } fn resolve_schema_ty( db: &dyn crate::Db, api: InputApi, - schema: &oapi::ReferenceOr, + schema: &oapi::RefOr, ) -> Type { schema_ty(db, api, resolve_schema(db, api, schema)) } fn shallow_schema_ty( db: &dyn crate::Db, api: InputApi, - schema: &oapi::ReferenceOr, + schema: &oapi::RefOr, ) -> Type { match schema { - oapi::ReferenceOr::Reference { reference } => { - if let Some(name) = reference.strip_prefix("#/components/schemas/") { + oapi::RefOr::Ref(reference) => { + if let Some(name) = reference.ref_location.strip_prefix("#/components/schemas/") { if name.contains('_') { resolve_schema_ty(db, api, schema) } else { @@ -174,74 +161,70 @@ fn shallow_schema_ty( todo!() } } - oapi::ReferenceOr::Item(schema) => { - schema_ty(db, api, Schema::from_oapi(db, schema.clone())) - } + oapi::RefOr::T(schema) => schema_ty(db, api, Schema::from_oapi(db, schema.clone())), } } fn ty_by_name(db: &dyn crate::Db, api: InputApi, name: String) -> Type { - shallow_schema_ty(db, api, &oapi::ReferenceOr::Reference { reference: name }) + shallow_schema_ty( + db, + api, + &oapi::RefOr::Ref(oapi::schema::RefBuilder::new().ref_location(name).build()), + ) } fn operation( db: &dyn crate::Db, api: InputApi, path: String, - operation: &oapi::Operation, + operation: &oapi::path::Operation, ) -> Operation { let mut path_params = BTreeMap::new(); let mut query = BTreeMap::new(); - for param in &operation.parameters { - match param { - oapi::ReferenceOr::Reference { .. } => todo!(), - oapi::ReferenceOr::Item(param) => match param { - oapi::Parameter::Query { parameter_data, .. } => { - let ty = match ¶meter_data.format { - oapi::ParameterSchemaOrContent::Schema(schema) => { - shallow_schema_ty(db, api, schema) - } - oapi::ParameterSchemaOrContent::Content(_) => todo!(), - }; - - query.insert(parameter_data.name.clone(), ty); - } - oapi::Parameter::Header { .. } => todo!(), - oapi::Parameter::Path { parameter_data, .. } => { - let ty = match ¶meter_data.format { - oapi::ParameterSchemaOrContent::Schema(schema) => { - shallow_schema_ty(db, api, schema) - } - oapi::ParameterSchemaOrContent::Content(_) => todo!(), - }; - - path_params.insert(parameter_data.name.clone(), ty); + for param in operation.parameters.iter().flat_map(|p| p.iter()) { + match param.parameter_in { + oapi::path::ParameterIn::Query => match ¶m.schema { + Some(oapi::RefOr::Ref(_)) => todo!(), + Some(oapi::RefOr::T(schema)) => { + let ty = shallow_schema_ty(db, api, &oapi::RefOr::T(schema.clone())); + query.insert(param.name.clone(), ty); } - oapi::Parameter::Cookie { .. } => todo!(), + None => {} }, + oapi::path::ParameterIn::Path => { + match ¶m.schema { + Some(oapi::RefOr::Ref(_)) => todo!(), + Some(oapi::RefOr::T(schema)) => { + let ty = shallow_schema_ty(db, api, &oapi::RefOr::T(schema.clone())); + path_params.insert(param.name.clone(), ty); + } + None => {} + }; + } + oapi::path::ParameterIn::Header => todo!(), + oapi::path::ParameterIn::Cookie => todo!(), } } let body = if let Some(body) = &operation.request_body { - match body { - oapi::ReferenceOr::Reference { .. } => todo!(), - oapi::ReferenceOr::Item(body) => { - assert_eq!(body.content.len(), 1); - - let (media_type, value) = body.content.iter().next().unwrap(); - let ty = if let Some(schema) = &value.schema { - let ty = simplify_ty(db, shallow_schema_ty(db, api, schema)); - let ts = ty.ts(db); - tracing::debug!(?media_type, ty=?ts, "request"); - ty - } else { - todo!() - }; - match media_type.as_str() { - "application/json" => Some(RequestKind::Json(ty)), - _ => todo!("unhandled request media type: {media_type:?}"), - } + assert_eq!(body.content.len(), 1, "only single content type supported"); + let (media_type, content) = body.content.iter().next().unwrap(); + let ty = match &content.schema { + None => todo!(), + Some(oapi::RefOr::Ref(reference)) => { + ty_by_name(db, api, reference.ref_location.clone()) } + Some(oapi::RefOr::T(schema)) => simplify_ty( + db, + shallow_schema_ty(db, api, &oapi::RefOr::T(schema.clone())), + ), + }; + + let ts = ty.ts(db); + tracing::debug!(?media_type, ty=?ts, "request"); + match media_type.as_str() { + "application/json" => Some(RequestKind::Json(ty)), + _ => todo!("unhandled request media type: {media_type:?}"), } } else { None @@ -264,8 +247,8 @@ fn operation( for (status, res) in &operation.responses.responses { response = match res { - oapi::ReferenceOr::Reference { .. } => todo!(), - oapi::ReferenceOr::Item(response) => { + oapi::RefOr::Ref(_) => todo!(), + oapi::RefOr::T(response) => { for (media_type, value) in &response.content { if let Some(schema) = &value.schema { let ty = simplify_ty(db, shallow_schema_ty(db, api, schema)).ts(db); @@ -314,120 +297,125 @@ fn schema_by_name(db: &dyn crate::Db, api: InputApi, name: String) -> Option { - todo!("reference to: {reference}") + oapi::RefOr::Ref(reference) => { + todo!("reference to: {reference:?}") } - oapi::ReferenceOr::Item(schema) => Some(Schema::from_oapi(db, schema.clone())), + oapi::RefOr::T(schema) => Some(Schema::from_oapi(db, schema.clone())), } } } -#[salsa::tracked] -fn schema_ty(db: &dyn crate::Db, api: InputApi, schema: Schema) -> Type { - match schema.kind(db) { - oapi::SchemaKind::Type(ty) => match ty { - oapi::Type::String(str) => { - if str.enumeration.is_empty() { - Type::new(db, TypeKind::String) - } else { - Type::new( - db, - TypeKind::Or( - str.enumeration - .iter() - .map(|e| Type::new(db, TypeKind::Ident(e.clone().unwrap()))) - .collect(), - ), - ) - } +fn ty_ty(db: &dyn crate::Db, api: InputApi, ty: &oapi::Type, obj: &oapi::Object) -> Type { + match ty { + oapi::schema::Type::String => match &obj.enum_values { + None => Type::new(db, TypeKind::String), + Some(str) if str.is_empty() => Type::new(db, TypeKind::String), + Some(str) => Type::new( + db, + TypeKind::Or( + str.iter() + .map(|e| match e { + serde_json::Value::String(s) => { + Type::new(db, TypeKind::Ident(s.clone())) + } + _ => unreachable!(), + }) + .collect(), + ), + ), + }, + + oapi::schema::Type::Array => todo!(), + + oapi::Type::Null => Type::new(db, TypeKind::Null), + + oapi::Type::Number | oapi::Type::Integer => Type::new(db, TypeKind::Number), + + oapi::Type::Boolean => Type::new(db, TypeKind::Boolean), + + oapi::Type::Object => { + let mut properties = BTreeMap::default(); + let required_fields = obj.required.clone(); + + for (name, prop) in &obj.properties { + let ty = shallow_schema_ty(db, api, &prop.clone()); + let required = required_fields.contains(&name.clone()); + properties.insert( + name.clone(), + Property { + ty, + optional: !required, + }, + ); } - oapi::Type::Number(_) | oapi::Type::Integer(_) => Type::new(db, TypeKind::Number), - oapi::Type::Object(obj) => { - let mut properties = BTreeMap::default(); - - for (name, prop) in &obj.properties { - let ty = shallow_schema_ty(db, api, &prop.clone().unbox()); - let required = obj.required.contains(name); - properties.insert( - name.clone(), - Property { - ty, - optional: !required, - }, - ); - } + Type::new(db, TypeKind::Object(properties)) + } + } +} - if let Some(disc) = &schema.data(db).discriminator { - assert!(disc.extensions.is_empty()); - - match disc.mapping.len() { - 0 => todo!(), - 1 => todo!(), - _ => Type::new( - db, - TypeKind::Or( - disc.mapping - .iter() - .map(|(name, rest)| { - let ty = ty_by_name(db, api, rest.clone()); - let marker = Type::new( - db, - TypeKind::Object( - [( - disc.property_name.clone(), - Property::required(Type::new( - db, - TypeKind::Ident(name.clone()), - )), - )] - .into_iter() - .collect(), - ), - ); - Type::new(db, TypeKind::And(vec![marker, ty])) - }) - .collect(), - ), - ), - } +#[salsa::tracked] +fn schema_ty(db: &dyn crate::Db, api: InputApi, schema: Schema) -> Type { + match schema.inner(db) { + oapi::Schema::Object(obj) => match &obj.schema_type { + oapi::schema::SchemaType::Type(ty) => ty_ty(db, api, ty, &obj), + oapi::schema::SchemaType::Array(array_ty) => { + let types: HashSet<_> = + array_ty.iter().map(|ty| ty_ty(db, api, ty, &obj)).collect(); + + if types.len() == 1 { + let ty = array_ty.first().unwrap(); + Type::new(db, TypeKind::Array(ty_ty(db, api, ty, &obj))) + } else if types.len() == 2 && types.contains(&Type::new(db, TypeKind::Null)) { + let non_null = types + .iter() + .find(|ty| !matches!(ty.kind(db), TypeKind::Null)) + .unwrap(); + simplify_ty(db, non_null.clone()) } else { - Type::new(db, TypeKind::Object(properties)) + Type::new(db, TypeKind::Tuple(types.into_iter().collect())) } } - oapi::Type::Array(array_ty) => { - let ty = shallow_schema_ty(db, api, &array_ty.items.clone().unwrap().unbox()); - match (array_ty.min_items, array_ty.max_items) { - (Some(min), Some(max)) if min == max => { - Type::new(db, TypeKind::Tuple(vec![ty; min])) - } - (None, None) => Type::new(db, TypeKind::Array(ty)), - (min, max) => todo!("{:?}", (min, max)), - } - } - oapi::Type::Boolean {} => Type::new(db, TypeKind::Boolean), + + oapi::schema::SchemaType::AnyValue => todo!(), }, - oapi::SchemaKind::OneOf { one_of } => Type::new( + oapi::Schema::OneOf(one_of) => Type::new( db, TypeKind::Or( one_of + .items .iter() .map(|item| shallow_schema_ty(db, api, item)) .collect(), ), ), - oapi::SchemaKind::AllOf { all_of } => Type::new( + oapi::Schema::AllOf(all_of) => Type::new( db, TypeKind::And( all_of + .items .iter() .map(|item| shallow_schema_ty(db, api, item)) .collect(), ), ), - oapi::SchemaKind::AnyOf { .. } => todo!(), - oapi::SchemaKind::Not { .. } => todo!(), - oapi::SchemaKind::Any(_) => todo!(), + oapi::Schema::Array(array) => match &array.items { + oapi::schema::ArrayItems::RefOrSchema(ref_or) => { + Type::new(db, TypeKind::Array(shallow_schema_ty(db, api, &ref_or))) + } + oapi::schema::ArrayItems::False => Type::new( + db, + TypeKind::Tuple( + array + .prefix_items + .iter() + .map(|item| shallow_schema_ty(db, api, &oapi::RefOr::T(item.clone()))) + .collect(), + ), + ), + }, + oapi::Schema::AnyOf { .. } => todo!(), + _ => todo!(), } } @@ -504,6 +492,10 @@ fn simplify_ty(db: &dyn crate::Db, ty: Type) -> Type { Type::new(db, TypeKind::And(options)) } } - TypeKind::Number | TypeKind::String | TypeKind::Boolean | TypeKind::Ident(_) => ty, + TypeKind::Number + | TypeKind::String + | TypeKind::Boolean + | TypeKind::Ident(_) + | TypeKind::Null => ty, } } diff --git a/src/main.rs b/src/main.rs index 9a694f5..011c0e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,8 @@ use abeye::{generate_ts, Config, Database, InputApi}; use camino::Utf8PathBuf; use clap::{Parser, Subcommand, ValueEnum}; use color_eyre::Result; -use openapiv3 as oapi; use tracing_subscriber::{filter::LevelFilter, prelude::*, EnvFilter}; +use utoipa::openapi as oapi; fn main() -> Result<()> { color_eyre::install()?; @@ -44,7 +44,7 @@ fn run() -> Result<()> { output, api_prefix, } => { - let api: oapi::OpenAPI = match source { + let api: oapi::OpenApi = match source { Some(s) if s.starts_with("http://") || s.starts_with("https://") => { tracing::info!(url=?s, "fetching schema"); reqwest::blocking::get(s)?.json()? diff --git a/src/ts.rs b/src/ts.rs index a76077c..37324bb 100644 --- a/src/ts.rs +++ b/src/ts.rs @@ -4,7 +4,7 @@ use camino::Utf8PathBuf; use heck::{ToLowerCamelCase, ToShoutySnakeCase}; use indent_write::indentable::Indentable; use itertools::Itertools; -use openapiv3 as oapi; +use utoipa::openapi as oapi; use crate::{ operation, schema_by_name, schema_ty, simplify_ty, InputApi, Operation, Property, RequestKind, @@ -24,33 +24,37 @@ pub fn generate_ts(db: &dyn crate::Db, api: InputApi) -> String { .paths .paths .iter() - .flat_map(|(path, item)| match item { - oapi::ReferenceOr::Reference { reference: _ } => todo!(), - oapi::ReferenceOr::Item(path_item) => { - let span = tracing::debug_span!("endpoint", path); - let _enter = span.enter(); - - if !path_item.parameters.is_empty() { - todo!() - } - - let gen_op = |method: &'static str, op: &Option| { - op.as_ref() - .map(|op| (method, operation(db, api, path.clone(), op))) - }; - [ - gen_op("DELETE", &path_item.delete), - gen_op("GET", &path_item.get), - gen_op("PUT", &path_item.put), - gen_op("POST", &path_item.post), - gen_op("HEAD", &path_item.head), - gen_op("TRACE", &path_item.trace), - gen_op("PATCH", &path_item.patch), - ] - .into_iter() - .flatten() - .map(|(method, op)| op.ts(db, api, method)) - } + // .filter_map(|(path, item)| { + // if item.parameters.is_none() || item.parameters.as_ref().unwrap().is_empty() { + // dbg!(item.parameters.is_some()); + // return None; + // } + // Some((path, item)) + // }) + .flat_map(|(path, item)| { + let span = tracing::debug_span!("endpoint", path); + let _enter = span.enter(); + + // if item.parameters.is_none() || item.parameters.as_ref().unwrap().is_empty() { + // unreachable!() + // } + + let gen_op = |method: &'static str, op: &Option| { + op.as_ref() + .map(|op| (method, operation(db, api, path.clone(), op))) + }; + [ + gen_op("DELETE", &item.delete), + gen_op("GET", &item.get), + gen_op("PUT", &item.put), + gen_op("POST", &item.post), + gen_op("HEAD", &item.head), + gen_op("TRACE", &item.trace), + gen_op("PATCH", &item.patch), + ] + .into_iter() + .flatten() + .map(|(method, op)| op.ts(db, api, method)) }) .collect_vec(); @@ -142,6 +146,7 @@ impl Type { TypeKind::String => "string".to_string(), TypeKind::Boolean => "boolean".to_string(), TypeKind::Ident(ident) => format!("{ident:?}"), + TypeKind::Null => "null".to_string(), } } } From 787776fe20daecc9fa7d3776f24f0e30548c184d Mon Sep 17 00:00:00 2001 From: Mikkel Denker Date: Tue, 19 Nov 2024 10:06:59 +0100 Subject: [PATCH 2/2] forgot to cleanup comments --- src/ts.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/ts.rs b/src/ts.rs index 55fe0dd..460d3b1 100644 --- a/src/ts.rs +++ b/src/ts.rs @@ -24,21 +24,10 @@ pub fn generate_ts(db: &dyn crate::Db, api: InputApi) -> String { .paths .paths .iter() - // .filter_map(|(path, item)| { - // if item.parameters.is_none() || item.parameters.as_ref().unwrap().is_empty() { - // dbg!(item.parameters.is_some()); - // return None; - // } - // Some((path, item)) - // }) .flat_map(|(path, item)| { let span = tracing::debug_span!("endpoint", path); let _enter = span.enter(); - // if item.parameters.is_none() || item.parameters.as_ref().unwrap().is_empty() { - // unreachable!() - // } - let gen_op = |method: &'static str, op: &Option| { op.as_ref() .map(|op| (method, operation(db, api, path.clone(), op)))