1use convert_case::{Case, Casing};
2use sql::util::pkey_column_names;
3use sql::{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 };
57 tables.push(table);
58 }
59 Ok(Schema { tables })
60 }
61}
62
63fn oaschema_to_sqltype(
64 schema: &oa::Schema,
65 options: &FromOpenApiOptions,
66) -> anyhow::Result<Option<Type>> {
67 use sql::Type::*;
68 let s = match &schema.kind {
69 oa::SchemaKind::Type(oa::Type::String(s)) => match s.format.as_str() {
70 "currency" => Numeric(19, 4),
71 "decimal" => Decimal,
72 "date" => Date,
73 "date-time" => DateTime,
74 _ => Text,
75 },
76 oa::SchemaKind::Type(oa::Type::Integer(_)) => {
77 let format = schema
78 .data
79 .extensions
80 .get("x-format")
81 .and_then(|v| v.as_str());
82 match format {
83 Some("date") => Date,
84 _ => I64,
85 }
86 }
87 oa::SchemaKind::Type(oa::Type::Boolean { .. }) => Boolean,
88 oa::SchemaKind::Type(oa::Type::Number(_)) => F64,
89 oa::SchemaKind::Type(oa::Type::Array(_)) => {
90 if options.include_arrays {
91 Jsonb
92 } else {
93 return Ok(None);
94 }
95 }
96 oa::SchemaKind::Type(oa::Type::Object(_)) => Jsonb,
97 _ => panic!("Unsupported type: {:#?}", schema),
98 };
99 Ok(Some(s))
100}
101
102fn schema_to_columns(
103 schema: &oa::Schema,
104 spec: &oa::OpenAPI,
105 options: &FromOpenApiOptions,
106) -> anyhow::Result<Option<Vec<Column>>> {
107 let mut columns = vec![];
108 let Some(props) = schema.get_properties() else {
109 return Ok(None);
110 };
111 for (name, prop) in props.into_iter() {
112 let prop = prop.resolve(spec);
113 let typ = oaschema_to_sqltype(prop, options)?;
114 let Some(typ) = typ else {
115 continue;
116 };
117 let mut primary_key = false;
118 if name == "id" {
119 primary_key = true;
120 }
121 let mut nullable = true;
122 if primary_key {
123 nullable = false;
124 }
125 if prop.is_required(&name) {
126 nullable = false;
127 }
128 if prop
129 .data
130 .extensions
131 .get("x-format")
132 .and_then(|v| v.as_str())
133 == Some("date")
134 {
135 nullable = true;
136 }
137 if prop
138 .data
139 .extensions
140 .get("x-null-as-zero")
141 .and_then(|v| v.as_bool())
142 .unwrap_or(false)
143 {
144 nullable = true;
145 }
146 let column = Column {
147 primary_key,
148 name: name.clone(),
149 typ,
150 nullable,
151 default: None,
152 constraint: None,
153 generated: 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}