Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More preparations for adding type templates #12

Merged
merged 3 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use indexmap::IndexMap;
use schemars::schema::{InstanceType, Schema};

use crate::{
types::{FieldType, Types},
types::{FieldType, Type, Types},
util::get_schema_name,
};

Expand Down Expand Up @@ -73,13 +73,18 @@ impl Api {
tracing::warn!(schema_name, "schema not found");
return None;
};
match s.json_schema {
let obj = match s.json_schema {
Schema::Bool(_) => {
tracing::warn!("found $ref'erenced bool schema, wat?!");
None
tracing::warn!(schema_name, "found $ref'erenced bool schema, wat?!");
return None;
}
Schema::Object(schema_object) => {
Some((schema_name.to_owned(), schema_object))
Schema::Object(o) => o,
};
match Type::from_schema(schema_name.to_owned(), obj) {
Ok(ty) => Some((schema_name.to_owned(), ty)),
Err(e) => {
tracing::warn!(schema_name, "unsupported schema: {e:#}");
None
}
}
})
Expand Down
259 changes: 233 additions & 26 deletions src/types.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,160 @@
use std::{borrow::Cow, collections::BTreeMap, sync::Arc};

use aide::openapi::{self};
use aide::openapi;
use anyhow::{bail, ensure, Context as _};
use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec};
use serde::Serialize;

use crate::util::get_schema_name;

/// Named types referenced by the [`Api`].
///
/// Intermediate representation of (some) `components` from the spec.
#[allow(dead_code)] // FIXME: Remove when we generate "model" files
#[derive(Debug)]
pub(crate) struct Types(pub BTreeMap<String, SchemaObject>);
pub(crate) struct Types(pub BTreeMap<String, Type>);

#[derive(Debug, Serialize)]
pub(crate) struct Type {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
deprecated: bool,
#[serde(flatten)]
data: TypeData,
}

impl Type {
pub(crate) fn from_schema(name: String, s: SchemaObject) -> anyhow::Result<Self> {
match s.instance_type {
Some(SingleOrVec::Single(it)) => match *it {
InstanceType::Object => {}
_ => bail!("unsupported type {it:?}"),
},
Some(SingleOrVec::Vec(_)) => bail!("unsupported: multiple types"),
None => bail!("unsupported: no type"),
}

let metadata = s.metadata.unwrap_or_default();

let obj = s
.object
.context("unsupported: object type without further validation")?;

ensure!(
obj.additional_properties.is_none(),
"additional_properties not yet supported"
);
ensure!(obj.max_properties.is_none(), "unsupported: max_properties");
ensure!(obj.min_properties.is_none(), "unsupported: min_properties");
ensure!(
obj.pattern_properties.is_empty(),
"unsupported: pattern_properties"
);
ensure!(obj.property_names.is_none(), "unsupported: property_names");

Ok(Self {
name,
description: metadata.description,
deprecated: metadata.deprecated,
data: TypeData::Struct {
fields: obj
.properties
.into_iter()
.map(|(name, schema)| {
Field::from_schema(name.clone(), schema, obj.required.contains(&name))
.with_context(|| format!("unsupported field {name}"))
})
.collect::<anyhow::Result<_>>()?,
},
})
}
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum TypeData {
Struct {
fields: Vec<Field>,
},
#[allow(dead_code)] // not _yet_ supported
Enum {
variants: Vec<Variant>,
},
}

#[derive(Debug, Serialize)]
pub(crate) struct Field {
name: String,
r#type: FieldType,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
required: bool,
nullable: bool,
deprecated: bool,
}

impl Field {
fn from_schema(name: String, s: Schema, required: bool) -> anyhow::Result<Self> {
let obj = match s {
Schema::Bool(_) => bail!("unsupported bool schema"),
Schema::Object(o) => o,
};
let metadata = obj.metadata.clone().unwrap_or_default();

ensure!(obj.const_value.is_none(), "unsupported const_value");
ensure!(obj.enum_values.is_none(), "unsupported enum_values");

let nullable = obj
.extensions
.get("nullable")
.and_then(|v| v.as_bool())
.unwrap_or(false);

Ok(Self {
name,
r#type: FieldType::from_schema_object(obj)?,
default: metadata.default,
description: metadata.description,
required,
nullable,
deprecated: metadata.deprecated,
})
}
}

#[derive(Debug, Serialize)]
pub(crate) struct Variant {
fields: Vec<Field>,
}

/// Supported field type.
///
/// Equivalent to openapi's `type` + `format` + `$ref`.
#[derive(Clone, Debug)]
pub(crate) enum FieldType {
Bool,
Int16,
UInt16,
Int32,
Int64,
UInt64,
String,
DateTime,
Uri,
/// A JSON object with arbitrary field values.
JsonObject,
/// A regular old list.
List(Box<FieldType>),
/// List with unique items.
Set(Box<FieldType>),
/// A map with a given value type.
///
/// The key type is always `String` in JSON schemas.
Map {
value_ty: Box<FieldType>,
},
SchemaRef(String),
}

Expand All @@ -32,40 +163,85 @@ impl FieldType {
let openapi::ParameterSchemaOrContent::Schema(s) = format else {
bail!("found unexpected 'content' data format");
};
Self::from_json_schema(s.json_schema)
Self::from_schema(s.json_schema)
}

fn from_json_schema(s: Schema) -> anyhow::Result<Self> {
fn from_schema(s: Schema) -> anyhow::Result<Self> {
let Schema::Object(obj) = s else {
bail!("found unexpected `true` schema");
};

Ok(match obj.instance_type {
Some(SingleOrVec::Single(ty)) => match *ty {
Self::from_schema_object(obj)
}

fn from_schema_object(obj: SchemaObject) -> anyhow::Result<FieldType> {
Ok(match &obj.instance_type {
Some(SingleOrVec::Single(ty)) => match **ty {
InstanceType::Boolean => Self::Bool,
InstanceType::Integer => match obj.format.as_deref() {
Some("uint64") => Self::UInt64,
Some("int16") => Self::Int16,
Some("uint16") => Self::UInt16,
Some("int32") => Self::Int32,
// FIXME: Why do we have int in the spec?
Some("int" | "int64") => Self::Int64,
// FIXME: Get rid of uint in the spec..
Some("uint" | "uint64") => Self::UInt64,
f => bail!("unsupported integer format: `{f:?}`"),
},
InstanceType::String => match obj.format.as_deref() {
None => Self::String,
Some("date-time") => Self::DateTime,
Some("uri") => Self::Uri,
Some(f) => bail!("unsupported string format: `{f:?}`"),
},
InstanceType::Array => {
let array = obj.array.context("array type must have array props")?;
ensure!(array.additional_items.is_none(), "not supported");
ensure!(
array.unique_items == Some(true),
"non-setlike arrays not currently supported"
);
let inner = match array.items.context("array type must have items prop")? {
SingleOrVec::Single(ty) => ty,
SingleOrVec::Vec(types) => {
bail!("unsupported multi-typed array parameter: `{types:?}`")
}
};
Self::Set(Box::new(Self::from_json_schema(*inner)?))
let inner = Box::new(Self::from_schema(*inner)?);
if array.unique_items == Some(true) {
Self::Set(inner)
} else {
Self::List(inner)
}
}
InstanceType::Object => {
let obj = obj
.object
.context("unsupported: object type without further validation")?;
let additional_properties = obj
.additional_properties
.context("unsupported: object field type without additional_properties")?;

ensure!(obj.max_properties.is_none(), "unsupported: max_properties");
ensure!(obj.min_properties.is_none(), "unsupported: min_properties");
ensure!(
obj.properties.is_empty(),
"unsupported: properties on field type"
);
ensure!(
obj.pattern_properties.is_empty(),
"unsupported: pattern_properties"
);
ensure!(obj.property_names.is_none(), "unsupported: property_names");
ensure!(
obj.required.is_empty(),
"unsupported: required on field type"
);

match *additional_properties {
Schema::Bool(true) => Self::JsonObject,
Schema::Bool(false) => bail!("unsupported `additional_properties: false`"),
Schema::Object(schema_object) => {
let value_ty = Box::new(Self::from_schema_object(schema_object)?);
Self::Map { value_ty }
}
}
}
ty => bail!("unsupported type: `{ty:?}`"),
},
Expand All @@ -82,60 +258,91 @@ impl FieldType {
fn to_csharp_typename(&self) -> Cow<'_, str> {
match self {
Self::Bool => "bool".into(),
Self::Int32 |
// FIXME: For backwards compatibility. Should be 'long'.
Self::UInt64 => "int".into(),
Self::Int64 | Self::UInt64 => "int".into(),
Self::String => "string".into(),
Self::DateTime => "DateTime".into(),
Self::Set(field_type) => format!("List<{}>", field_type.to_csharp_typename()).into(),
Self::Int16 | Self::UInt16 | Self::Uri | Self::JsonObject | Self::Map { .. } => todo!(),
// FIXME: Treat set differently?
Self::List(field_type) | Self::Set(field_type) => {
format!("List<{}>", field_type.to_csharp_typename()).into()
}
Self::SchemaRef(name) => name.clone().into(),
}
}

fn to_go_typename(&self) -> Cow<'_, str> {
match self {
Self::Bool => "bool".into(),
Self::Int32 |
// FIXME: Looks like all integers are currently i32
Self::UInt64 => "int32".into(),
Self::Int64 | Self::UInt64 => "int32".into(),
Self::String => "string".into(),
Self::DateTime => "time.Time".into(),
Self::Set(field_type) => format!("[]{}", field_type.to_go_typename()).into(),
Self::Int16 | Self::UInt16 | Self::Uri | Self::JsonObject | Self::Map { .. } => todo!(),
Self::List(field_type) | Self::Set(field_type) => {
format!("[]{}", field_type.to_go_typename()).into()
}
Self::SchemaRef(name) => name.clone().into(),
}
}

fn to_kotlin_typename(&self) -> Cow<'_, str> {
match self {
Self::Bool => "Boolean".into(),
Self::Int32 |
// FIXME: Should be Long..
Self::UInt64 => "Int".into(),
Self::Int64 | Self::UInt64 => "Int".into(),
Self::String => "String".into(),
Self::DateTime => "OffsetDateTime".into(),
Self::Set(field_type) => format!("List<{}>", field_type.to_kotlin_typename()).into(),
Self::Int16 | Self::UInt16 | Self::Uri | Self::JsonObject | Self::Map { .. } => todo!(),
// FIXME: Treat set differently?
Self::List(field_type) | Self::Set(field_type) => {
format!("List<{}>", field_type.to_kotlin_typename()).into()
}
Self::SchemaRef(name) => name.clone().into(),
}
}

fn to_js_typename(&self) -> Cow<'_, str> {
match self {
Self::Bool => "boolean".into(),
Self::UInt64 => "number".into(),
Self::Int16 | Self::UInt16 | Self::Int32 | Self::Int64 | Self::UInt64 => {
"number".into()
}
Self::String => "string".into(),
Self::DateTime => "Date | null".into(),
Self::Set(field_type) => format!("{}[]", field_type.to_js_typename()).into(),
Self::Uri | Self::JsonObject | Self::Map { .. } => todo!(),
Self::List(field_type) | Self::Set(field_type) => {
format!("{}[]", field_type.to_js_typename()).into()
}
Self::SchemaRef(name) => name.clone().into(),
}
}

fn to_rust_typename(&self) -> Cow<'_, str> {
match self {
Self::Bool => "bool".into(),
// FIXME: Looks like all integers are currently i32
Self::UInt64 => "i32".into(),
Self::String => "String".into(),
Self::Int16 => "i16".into(),
Self::UInt16 => "u16".into(),
Self::Int32 |
// FIXME: All integers in query params are currently i32
Self::Int64 | Self::UInt64 => "i32".into(),
// FIXME: Do we want a separate type for Uri?
Self::Uri | Self::String => "String".into(),
// FIXME: Depends on those chrono imports being in scope, not that great..
Self::DateTime => "DateTime<Utc>".into(),
// FIXME: Use BTreeSet
Self::Set(field_type) => format!("Vec<{}>", field_type.to_rust_typename()).into(),
Self::JsonObject => "serde_json::Value".into(),
// FIXME: Treat set differently? (BTreeSet)
Self::List(field_type) | Self::Set(field_type) => {
format!("Vec<{}>", field_type.to_rust_typename()).into()
}
Self::Map { value_ty } => format!(
"std::collections::HashMap<String, {}>",
value_ty.to_rust_typename(),
)
.into(),
Self::SchemaRef(name) => name.clone().into(),
}
}
Expand Down