use std::collections::{BTreeMap, BTreeSet, HashMap};
use oas3::spec::{ObjectOrReference, ObjectSchema, SchemaType, SchemaTypeSet};
#[derive(Debug, Clone)]
pub struct SchemaExpectation {
pub fields: BTreeMap<String, FieldExpectation>,
pub required_fields: BTreeSet<String>,
}
#[derive(Debug, Clone)]
pub struct FieldExpectation {
pub field_type: String,
pub nullable: bool,
pub nested: Option<SchemaExpectation>,
pub items: Option<Box<FieldExpectation>>,
}
pub fn load_openapi_spec() -> oas3::spec::Spec {
let yaml_str = std::fs::read_to_string("better-auth.yaml")
.expect("better-auth.yaml must exist in the project root");
let yaml_str: String = yaml_str
.lines()
.map(|line| {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("type: date") {
if rest.is_empty()
|| rest.starts_with(' ')
|| rest.starts_with('#')
|| rest.starts_with('\t')
{
line.replacen("type: date", "type: string", 1)
} else {
line.to_string()
}
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
oas3::from_yaml(yaml_str).expect("better-auth.yaml must be valid OpenAPI 3.1")
}
pub fn get_path_item<'a>(
spec: &'a oas3::spec::Spec,
path: &str,
) -> Option<&'a oas3::spec::PathItem> {
spec.paths.as_ref()?.get(path)
}
pub fn get_operation<'a>(
path_item: &'a oas3::spec::PathItem,
method: &str,
) -> Option<&'a oas3::spec::Operation> {
match method {
"get" => path_item.get.as_ref(),
"post" => path_item.post.as_ref(),
"put" => path_item.put.as_ref(),
"delete" => path_item.delete.as_ref(),
"patch" => path_item.patch.as_ref(),
_ => None,
}
}
pub fn resolve_object_schema(
spec: &oas3::spec::Spec,
obj_or_ref: &ObjectOrReference<ObjectSchema>,
) -> Option<ObjectSchema> {
match obj_or_ref {
ObjectOrReference::Object(obj) => Some(obj.clone()),
ObjectOrReference::Ref { .. } => obj_or_ref.resolve(spec).ok(),
}
}
pub fn schema_from_response(
spec: &oas3::spec::Spec,
response: &ObjectOrReference<oas3::spec::Response>,
) -> Option<ObjectSchema> {
let resp = match response {
ObjectOrReference::Object(r) => r.clone(),
ObjectOrReference::Ref { .. } => response.resolve(spec).ok()?,
};
let media = resp.content.get("application/json")?;
let schema_ref = media.schema.as_ref()?;
resolve_object_schema(spec, schema_ref)
}
pub fn extract_success_schema(
spec: &oas3::spec::Spec,
path: &str,
method: &str,
) -> Option<SchemaExpectation> {
let path_item = get_path_item(spec, path)?;
let operation = get_operation(path_item, method)?;
let response = operation.responses.as_ref()?.get("200")?;
let obj_schema = schema_from_response(spec, response)?;
Some(object_schema_to_expectation(spec, &obj_schema))
}
pub fn extract_error_schemas(
spec: &oas3::spec::Spec,
path: &str,
method: &str,
) -> HashMap<String, SchemaExpectation> {
let mut result = HashMap::new();
let Some(path_item) = get_path_item(spec, path) else {
return result;
};
let Some(operation) = get_operation(path_item, method) else {
return result;
};
let Some(responses) = &operation.responses else {
return result;
};
for (status, response) in responses {
if status.starts_with('4') || status.starts_with('5') {
if let Some(obj_schema) = schema_from_response(spec, response) {
result.insert(
status.clone(),
object_schema_to_expectation(spec, &obj_schema),
);
}
}
}
result
}
pub fn object_schema_to_expectation(
spec: &oas3::spec::Spec,
obj: &ObjectSchema,
) -> SchemaExpectation {
let mut fields = BTreeMap::new();
let required_fields: BTreeSet<String> = obj.required.iter().cloned().collect();
for (name, prop_ref) in &obj.properties {
if let Some(prop_schema) = resolve_object_schema(spec, prop_ref) {
fields.insert(name.clone(), object_schema_to_field(spec, &prop_schema));
} else {
fields.insert(
name.clone(),
FieldExpectation {
field_type: "string".to_string(),
nullable: false,
nested: None,
items: None,
},
);
}
}
SchemaExpectation {
fields,
required_fields,
}
}
pub fn object_schema_to_field(spec: &oas3::spec::Spec, obj: &ObjectSchema) -> FieldExpectation {
let (field_type, nullable) = schema_type_info(obj);
let nested = if field_type == "object" && !obj.properties.is_empty() {
Some(object_schema_to_expectation(spec, obj))
} else {
None
};
let items = if field_type == "array" {
obj.items
.as_ref()
.and_then(|item_schema| match item_schema.as_ref() {
oas3::spec::Schema::Object(boxed) => resolve_object_schema(spec, boxed)
.map(|s| Box::new(object_schema_to_field(spec, &s))),
oas3::spec::Schema::Boolean(_) => None,
})
} else {
None
};
FieldExpectation {
field_type,
nullable,
nested,
items,
}
}
pub fn schema_type_info(obj: &ObjectSchema) -> (String, bool) {
match &obj.schema_type {
Some(SchemaTypeSet::Single(t)) => (schema_type_to_string(*t), *t == SchemaType::Null),
Some(SchemaTypeSet::Multiple(types)) => {
let nullable = types.contains(&SchemaType::Null);
let primary = types
.iter()
.find(|t| **t != SchemaType::Null)
.copied()
.unwrap_or(SchemaType::String);
(schema_type_to_string(primary), nullable)
}
None => {
if !obj.properties.is_empty() {
("object".to_string(), false)
} else {
("string".to_string(), false)
}
}
}
}
pub fn schema_type_to_string(t: SchemaType) -> String {
match t {
SchemaType::Boolean => "boolean",
SchemaType::Integer => "integer",
SchemaType::Number => "number",
SchemaType::String => "string",
SchemaType::Array => "array",
SchemaType::Object => "object",
SchemaType::Null => "null",
}
.to_string()
}