fraiseql-server 2.3.0

HTTP server for FraiseQL v2 GraphQL engine
//! Type conversion and formatting utilities for `OpenAPI` specification generation.
//!
//! Converts Rust types to JSON Schema representations, maps HTTP methods to strings,
//! and provides string transformation helpers used throughout `OpenAPI` spec building.

use fraiseql_core::schema::FieldType;
use serde_json::{Value, json};

use super::super::resource::{HttpMethod, RestRoute};

/// Bracket operators documented in filter parameter descriptions.
pub(super) const BRACKET_OPERATORS_DESC: &str = "eq, ne, gt, gte, lt, lte, in, nin, like, ilike, is_null, contains, icontains, startswith, endswith";

/// Map a `FieldType` to a JSON Schema type object.
pub(super) fn field_type_to_json_schema(ft: &FieldType) -> Value {
    match ft {
        FieldType::Int => json!({ "type": "integer" }),
        FieldType::Float => json!({ "type": "number" }),
        FieldType::Boolean => json!({ "type": "boolean" }),
        FieldType::Id | FieldType::Uuid => json!({ "type": "string", "format": "uuid" }),
        FieldType::DateTime => json!({ "type": "string", "format": "date-time" }),
        FieldType::Date => json!({ "type": "string", "format": "date" }),
        FieldType::Time => json!({ "type": "string", "format": "time" }),
        FieldType::Json => json!({ "type": "object" }),
        FieldType::Decimal => json!({ "type": "string", "format": "decimal" }),
        FieldType::Vector => json!({ "type": "array", "items": { "type": "number" } }),
        FieldType::Scalar(name) => scalar_to_json_schema(name),
        FieldType::List(inner) => {
            json!({ "type": "array", "items": field_type_to_json_schema(inner) })
        },
        FieldType::Object(name) | FieldType::Enum(name) | FieldType::Input(name) => {
            json!({ "$ref": format!("#/components/schemas/{name}") })
        },
        FieldType::Interface(name) | FieldType::Union(name) => {
            json!({ "type": "object", "description": format!("See {name}") })
        },
        // Reason: FieldType is #[non_exhaustive]; default to string for unknown variants.
        _ => json!({ "type": "string" }),
    }
}

/// Map well-known scalar names to JSON Schema.
pub(super) fn scalar_to_json_schema(name: &str) -> Value {
    match name {
        "Email" => json!({ "type": "string", "format": "email" }),
        "URL" | "Uri" => json!({ "type": "string", "format": "uri" }),
        "PhoneNumber" => json!({ "type": "string", "format": "phone" }),
        _ => json!({ "type": "string" }),
    }
}

/// Convert an HTTP method to its string representation.
pub(super) const fn method_to_string(method: HttpMethod) -> &'static str {
    match method {
        HttpMethod::Get => "get",
        HttpMethod::Post => "post",
        HttpMethod::Put => "put",
        HttpMethod::Patch => "patch",
        HttpMethod::Delete => "delete",
    }
}

/// Determine if a route should include a Prefer header in its `OpenAPI` documentation.
pub(super) fn should_have_prefer_header(route: &RestRoute) -> bool {
    match route.method {
        HttpMethod::Get => {
            // Collection GET endpoints (no path parameter).
            !route.path.contains('{')
        },
        HttpMethod::Post | HttpMethod::Patch | HttpMethod::Delete => true,
        HttpMethod::Put => false,
    }
}

/// Capitalize the first letter of a string.
pub(super) fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
    }
}

/// Convert a string to `snake_case`.
pub(super) fn to_snake(s: &str) -> String {
    let mut result = String::new();
    for (i, c) in s.chars().enumerate() {
        if c.is_uppercase() {
            if i > 0 {
                result.push('_');
            }
            result.extend(c.to_lowercase());
        } else {
            result.push(c);
        }
    }
    result
}

/// Extract an action name from a mutation name by stripping the type prefix.
///
/// Example: `archiveUser` on type `User` → `archive`
pub(super) fn extract_action(mutation_name: &str, type_name: &str) -> String {
    // Try stripping type name suffix (e.g., `archiveUser` → `archive`).
    let lower_type = type_name.to_lowercase();
    let lower_name = mutation_name.to_lowercase();

    if let Some(prefix) = lower_name.strip_suffix(&lower_type) {
        if !prefix.is_empty() {
            return prefix.trim_end_matches('_').replace('_', "-");
        }
    }

    // Try stripping type name prefix (e.g., `userArchive` → `archive`).
    if let Some(suffix) = lower_name.strip_prefix(&lower_type) {
        let trimmed = suffix.trim_start_matches('_');
        if !trimmed.is_empty() {
            return trimmed.replace('_', "-");
        }
    }

    // Fallback: use the full mutation name kebab-cased.
    to_snake(mutation_name).replace('_', "-")
}