kurtbuilds_sql_openapi 0.26.2

Crate to create sql models from openapi v3 spec
Documentation
use convert_case::{Case, Casing};
use sql::util::pkey_column_names;
use sql::{Column, Schema, Table, Type};

use openapiv3 as oa;

pub trait FromOpenApi: Sized {
    fn try_from_openapi(
        spec: openapiv3::OpenAPI,
        options: &FromOpenApiOptions,
    ) -> anyhow::Result<Self>;
}

#[derive(Debug, Clone)]
pub struct FromOpenApiOptions {
    pub include_arrays: bool,
    pub include_schemas: Vec<String>,
}

impl Default for FromOpenApiOptions {
    fn default() -> Self {
        Self {
            include_arrays: false,
            include_schemas: vec![],
        }
    }
}

impl FromOpenApi for Schema {
    fn try_from_openapi(spec: oa::OpenAPI, options: &FromOpenApiOptions) -> anyhow::Result<Self> {
        let mut tables = Vec::new();
        for (schema_name, schema) in spec.schemas.iter().filter(|(schema_name, _)| {
            if options.include_schemas.contains(schema_name) {
                true
            } else if schema_name.ends_with("Response") {
                false
            } else {
                true
            }
        }) {
            let schema = schema.resolve(&spec);
            let Some(mut columns) = schema_to_columns(&schema, &spec, options)? else {
                continue;
            };
            let pkey_candidates = pkey_column_names(&schema_name);
            for col in &mut columns {
                if pkey_candidates.contains(&col.name) {
                    col.primary_key = true;
                    break;
                }
            }
            let table = Table {
                schema: None,
                name: schema_name.to_case(Case::Snake),
                columns,
            };
            tables.push(table);
        }
        Ok(Schema { tables })
    }
}

fn oaschema_to_sqltype(
    schema: &oa::Schema,
    options: &FromOpenApiOptions,
) -> anyhow::Result<Option<Type>> {
    use sql::Type::*;
    let s = match &schema.kind {
        oa::SchemaKind::Type(oa::Type::String(s)) => match s.format.as_str() {
            "currency" => Numeric(19, 4),
            "decimal" => Decimal,
            "date" => Date,
            "date-time" => DateTime,
            _ => Text,
        },
        oa::SchemaKind::Type(oa::Type::Integer(_)) => {
            let format = schema
                .data
                .extensions
                .get("x-format")
                .and_then(|v| v.as_str());
            match format {
                Some("date") => Date,
                _ => I64,
            }
        }
        oa::SchemaKind::Type(oa::Type::Boolean { .. }) => Boolean,
        oa::SchemaKind::Type(oa::Type::Number(_)) => F64,
        oa::SchemaKind::Type(oa::Type::Array(_)) => {
            if options.include_arrays {
                Jsonb
            } else {
                return Ok(None);
            }
        }
        oa::SchemaKind::Type(oa::Type::Object(_)) => Jsonb,
        _ => panic!("Unsupported type: {:#?}", schema),
    };
    Ok(Some(s))
}

fn schema_to_columns(
    schema: &oa::Schema,
    spec: &oa::OpenAPI,
    options: &FromOpenApiOptions,
) -> anyhow::Result<Option<Vec<Column>>> {
    let mut columns = vec![];
    let Some(props) = schema.get_properties() else {
        return Ok(None);
    };
    for (name, prop) in props.into_iter() {
        let prop = prop.resolve(spec);
        let typ = oaschema_to_sqltype(prop, options)?;
        let Some(typ) = typ else {
            continue;
        };
        let mut primary_key = false;
        if name == "id" {
            primary_key = true;
        }
        let mut nullable = true;
        if primary_key {
            nullable = false;
        }
        if prop.is_required(&name) {
            nullable = false;
        }
        if prop
            .data
            .extensions
            .get("x-format")
            .and_then(|v| v.as_str())
            == Some("date")
        {
            nullable = true;
        }
        if prop
            .data
            .extensions
            .get("x-null-as-zero")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        {
            nullable = true;
        }
        let column = Column {
            primary_key,
            name: name.clone(),
            typ,
            nullable,
            default: None,
            constraint: None,
            generated: None,
        };
        columns.push(column);
    }
    Ok(Some(columns))
}

#[cfg(test)]
mod test {
    use openapiv3::OpenAPI;

    use super::*;

    use openapiv3 as oa;

    #[test]
    fn test_format_date() {
        let mut z = oa::Schema::new_object();

        let mut int_format_date = oa::Schema::new_integer();
        int_format_date
            .data
            .extensions
            .insert("x-format".to_string(), serde_json::Value::from("date"));
        z.properties_mut().insert("date", int_format_date);

        let mut int_null_as_zero = oa::Schema::new_integer();
        int_null_as_zero
            .data
            .extensions
            .insert("x-null-as-zero".to_string(), serde_json::Value::from(true));
        z.properties_mut()
            .insert("int_null_as_zero", int_null_as_zero);

        let columns = schema_to_columns(&z, &OpenAPI::default(), &FromOpenApiOptions::default())
            .unwrap()
            .unwrap();
        assert_eq!(columns.len(), 2);

        let int_format_date = &columns[0];
        assert_eq!(int_format_date.name, "date");
        assert_eq!(int_format_date.nullable, true);

        let int_null_as_zero = &columns[1];
        assert_eq!(int_null_as_zero.name, "int_null_as_zero");
        assert_eq!(int_null_as_zero.nullable, true);
    }

    #[test]
    fn test_oasformat() {
        let z = oa::Schema::new_string().with_format("currency");
        let t = oaschema_to_sqltype(&z, &FromOpenApiOptions::default())
            .unwrap()
            .unwrap();
        assert_eq!(t, Type::Numeric(19, 4));

        let z = oa::Schema::new_string().with_format("decimal");
        let t = oaschema_to_sqltype(&z, &FromOpenApiOptions::default())
            .unwrap()
            .unwrap();
        assert_eq!(t, Type::Decimal);
    }
}