sqlmo_openapi/
lib.rs

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}