1use convert_case::{Case, Casing};
2use sqlmo::util::pkey_column_names;
3use sqlmo::{Column, Schema, Table, Type};
4
5use openapiv3 as oa;
6
7pub trait FromOpenApi: Sized {
8 fn try_from_openapi(
9 spec: openapiv3::OpenAPI,
10 options: &FromOpenApiOptions,
11 ) -> anyhow::Result<Self>;
12}
13
14#[derive(Debug, Clone)]
15pub struct FromOpenApiOptions {
16 pub include_arrays: bool,
17 pub include_schemas: Vec<String>,
18}
19
20impl Default for FromOpenApiOptions {
21 fn default() -> Self {
22 Self {
23 include_arrays: false,
24 include_schemas: vec![],
25 }
26 }
27}
28
29impl FromOpenApi for Schema {
30 fn try_from_openapi(spec: oa::OpenAPI, options: &FromOpenApiOptions) -> anyhow::Result<Self> {
31 let mut tables = Vec::new();
32 for (schema_name, schema) in spec.schemas.iter().filter(|(schema_name, _)| {
33 if options.include_schemas.contains(schema_name) {
34 true
35 } else if schema_name.ends_with("Response") {
36 false
37 } else {
38 true
39 }
40 }) {
41 let schema = schema.resolve(&spec);
42 let Some(mut columns) = schema_to_columns(&schema, &spec, options)? else {
43 continue;
44 };
45 let pkey_candidates = pkey_column_names(&schema_name);
46 for col in &mut columns {
47 if pkey_candidates.contains(&col.name) {
48 col.primary_key = true;
49 break;
50 }
51 }
52 let table = Table {
53 schema: None,
54 name: schema_name.to_case(Case::Snake),
55 columns,
56 indexes: vec![],
57 };
58 tables.push(table);
59 }
60 Ok(Schema { tables })
61 }
62}
63
64fn oaschema_to_sqltype(
65 schema: &oa::Schema,
66 options: &FromOpenApiOptions,
67) -> anyhow::Result<Option<Type>> {
68 use sqlmo::Type::*;
69 let s = match &schema.kind {
70 oa::SchemaKind::Type(oa::Type::String(s)) => match s.format.as_str() {
71 "currency" => Numeric(19, 4),
72 "decimal" => Decimal,
73 "date" => Date,
74 "date-time" => DateTime,
75 _ => Text,
76 },
77 oa::SchemaKind::Type(oa::Type::Integer(_)) => {
78 let format = schema
79 .data
80 .extensions
81 .get("x-format")
82 .and_then(|v| v.as_str());
83 match format {
84 Some("date") => Date,
85 _ => I64,
86 }
87 }
88 oa::SchemaKind::Type(oa::Type::Boolean { .. }) => Boolean,
89 oa::SchemaKind::Type(oa::Type::Number(_)) => F64,
90 oa::SchemaKind::Type(oa::Type::Array(_)) => {
91 if options.include_arrays {
92 Jsonb
93 } else {
94 return Ok(None);
95 }
96 }
97 oa::SchemaKind::Type(oa::Type::Object(_)) => Jsonb,
98 _ => panic!("Unsupported type: {:#?}", schema),
99 };
100 Ok(Some(s))
101}
102
103fn schema_to_columns(
104 schema: &oa::Schema,
105 spec: &oa::OpenAPI,
106 options: &FromOpenApiOptions,
107) -> anyhow::Result<Option<Vec<Column>>> {
108 let mut columns = vec![];
109 let Some(props) = schema.get_properties() else {
110 return Ok(None);
111 };
112 for (name, prop) in props.into_iter() {
113 let prop = prop.resolve(spec);
114 let typ = oaschema_to_sqltype(prop, options)?;
115 let Some(typ) = typ else {
116 continue;
117 };
118 let mut primary_key = false;
119 if name == "id" {
120 primary_key = true;
121 }
122 let mut nullable = true;
123 if primary_key {
124 nullable = false;
125 }
126 if prop.is_required(&name) {
127 nullable = false;
128 }
129 if prop
130 .data
131 .extensions
132 .get("x-format")
133 .and_then(|v| v.as_str())
134 == Some("date")
135 {
136 nullable = true;
137 }
138 if prop
139 .data
140 .extensions
141 .get("x-null-as-zero")
142 .and_then(|v| v.as_bool())
143 .unwrap_or(false)
144 {
145 nullable = true;
146 }
147 let column = Column {
148 primary_key,
149 name: name.clone(),
150 typ,
151 nullable,
152 default: None,
153 constraint: None,
154 };
155 columns.push(column);
156 }
157 Ok(Some(columns))
158}
159
160#[cfg(test)]
161mod test {
162 use openapiv3::OpenAPI;
163
164 use super::*;
165
166 use openapiv3 as oa;
167
168 #[test]
169 fn test_format_date() {
170 let mut z = oa::Schema::new_object();
171
172 let mut int_format_date = oa::Schema::new_integer();
173 int_format_date
174 .data
175 .extensions
176 .insert("x-format".to_string(), serde_json::Value::from("date"));
177 z.properties_mut().insert("date", int_format_date);
178
179 let mut int_null_as_zero = oa::Schema::new_integer();
180 int_null_as_zero
181 .data
182 .extensions
183 .insert("x-null-as-zero".to_string(), serde_json::Value::from(true));
184 z.properties_mut()
185 .insert("int_null_as_zero", int_null_as_zero);
186
187 let columns = schema_to_columns(&z, &OpenAPI::default(), &FromOpenApiOptions::default())
188 .unwrap()
189 .unwrap();
190 assert_eq!(columns.len(), 2);
191
192 let int_format_date = &columns[0];
193 assert_eq!(int_format_date.name, "date");
194 assert_eq!(int_format_date.nullable, true);
195
196 let int_null_as_zero = &columns[1];
197 assert_eq!(int_null_as_zero.name, "int_null_as_zero");
198 assert_eq!(int_null_as_zero.nullable, true);
199 }
200
201 #[test]
202 fn test_oasformat() {
203 let z = oa::Schema::new_string().with_format("currency");
204 let t = oaschema_to_sqltype(&z, &FromOpenApiOptions::default())
205 .unwrap()
206 .unwrap();
207 assert_eq!(t, Type::Numeric(19, 4));
208
209 let z = oa::Schema::new_string().with_format("decimal");
210 let t = oaschema_to_sqltype(&z, &FromOpenApiOptions::default())
211 .unwrap()
212 .unwrap();
213 assert_eq!(t, Type::Decimal);
214 }
215}