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 3cfe143..c6951ec 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 930e838..460d3b1 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,26 @@ 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)) - } + .flat_map(|(path, item)| { + let span = tracing::debug_span!("endpoint", path); + let _enter = span.enter(); + + 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 +135,7 @@ impl Type { TypeKind::String => "string".to_string(), TypeKind::Boolean => "boolean".to_string(), TypeKind::Ident(ident) => format!("{ident:?}"), + TypeKind::Null => "null".to_string(), } } }