sql_openapi/
lib.rs

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}