aiscript-runtime 0.2.0

The web runtime of AIScript programming language interpreter
Documentation
use aiscript_directive::Validator;
use aiscript_directive::route::{Auth, RouteAnnotation};
use aiscript_directive::validator::{InValidator, StringValidator};
use oas3::{
    Spec,
    spec::{
        Components, Info, MediaType, ObjectOrReference, ObjectSchema, Operation, Parameter,
        ParameterIn, ParameterStyle, PathItem, RequestBody, Response, SchemaType as Type,
        SchemaTypeSet, SecurityScheme, Server, Tag,
    },
};
use std::collections::BTreeMap;

use crate::ast::{BodyKind, Endpoint, Field, FieldType, HttpMethod, PathSpec, Route};

pub struct OpenAPIGenerator;

impl OpenAPIGenerator {
    pub fn generate(routes: &[Route]) -> Spec {
        let mut paths = BTreeMap::new();
        let mut tags = Vec::new();

        for route in routes {
            let route_name = route.prefix.trim_matches('/').to_string();

            if !route_name.is_empty() {
                tags.push(Tag {
                    name: route_name.clone(),
                    description: Some(route.docs.clone()),
                    extensions: BTreeMap::new(),
                });
            }

            for endpoint in &route.endpoints {
                for path_spec in &endpoint.path_specs {
                    let path_item = Self::create_path_item(route, endpoint, path_spec);
                    let path = if route.prefix.starts_with('/') {
                        route.prefix.clone()
                    } else {
                        format!("/{}", route.prefix)
                    };
                    let full_path = if path_spec.path.starts_with('/') {
                        format!("{}{}", path, path_spec.path)
                    } else {
                        format!("{}/{}", path, path_spec.path)
                    };
                    // Skip hidden routes if applicable
                    if route.annotation.docs.as_ref().is_some_and(|d| d.hidden) {
                        continue;
                    }
                    paths.insert(full_path.replace("//", "/"), path_item);
                }
            }
        }

        Spec {
            openapi: "3.0.3".to_string(),
            info: Info {
                title: "API Documentation".to_string(),
                summary: None,
                description: Some("API Documentation generated from routes".to_string()),
                terms_of_service: None,
                version: "1.0.0".to_string(),
                contact: None,
                license: None,
                extensions: BTreeMap::new(),
            },
            servers: vec![Server {
                url: "/".to_string(),
                description: None,
                variables: BTreeMap::new(),
            }],
            paths: Some(paths),
            components: Some(Self::create_default_components()),
            tags,
            webhooks: BTreeMap::new(),
            external_docs: None,
            extensions: BTreeMap::new(),
        }
    }

    fn create_default_components() -> Components {
        let mut security_schemes = BTreeMap::new();

        security_schemes.insert(
            "jwtAuth".to_string(),
            ObjectOrReference::Object(SecurityScheme::Http {
                description: Some("JWT Authentication".to_string()),
                scheme: "bearer".to_string(),
                bearer_format: Some("JWT".to_string()),
            }),
        );

        security_schemes.insert(
            "basicAuth".to_string(),
            ObjectOrReference::Object(SecurityScheme::Http {
                description: Some("Basic Authentication".to_string()),
                scheme: "basic".to_string(),
                bearer_format: None,
            }),
        );

        Components {
            schemas: BTreeMap::new(),
            responses: BTreeMap::new(),
            parameters: BTreeMap::new(),
            examples: BTreeMap::new(),
            request_bodies: BTreeMap::new(),
            headers: BTreeMap::new(),
            path_items: BTreeMap::new(),
            security_schemes,
            links: BTreeMap::new(),
            callbacks: BTreeMap::new(),
            extensions: BTreeMap::new(),
        }
    }

    fn create_path_item(route: &Route, endpoint: &Endpoint, path_spec: &PathSpec) -> PathItem {
        let mut path_item = PathItem {
            summary: Some(endpoint.docs.clone()),
            parameters: Self::create_path_parameters(endpoint, &path_spec.params),
            ..Default::default()
        };

        let operation = Self::create_operation(route, endpoint, path_spec);

        match path_spec.method {
            HttpMethod::Get => path_item.get = Some(operation),
            HttpMethod::Post => path_item.post = Some(operation),
            HttpMethod::Put => path_item.put = Some(operation),
            HttpMethod::Delete => path_item.delete = Some(operation),
        }

        path_item
    }

    #[allow(unused)]
    fn get_security_requirement(
        annotation: RouteAnnotation,
    ) -> Option<Vec<BTreeMap<String, Vec<String>>>> {
        match annotation.auth {
            Auth::Jwt => {
                let mut requirement = BTreeMap::new();
                requirement.insert("jwtAuth".to_string(), vec![]);
                Some(vec![requirement])
            }
            Auth::Basic => {
                let mut requirement = BTreeMap::new();
                requirement.insert("basicAuth".to_string(), vec![]);
                Some(vec![requirement])
            }
            Auth::None => None,
        }
    }

    fn create_operation(route: &Route, endpoint: &Endpoint, path_spec: &PathSpec) -> Operation {
        let route_name = route.prefix.trim_matches('/').to_string();
        let mut tags = if route_name.is_empty() {
            vec!["default".to_string()]
        } else {
            vec![route_name]
        };

        // Add custom tag from docs directive if present
        if let Some(docs) = &route.annotation.docs {
            if let Some(tag) = &docs.tag {
                tags = vec![tag.clone()];
            }
        }

        // Create path parameters using our enhanced function that leverages endpoint.path fields
        let mut parameters = Self::create_path_parameters(endpoint, &path_spec.params);

        // Add query parameters as before
        for query_field in &endpoint.query {
            parameters.push(Self::create_query_parameter(query_field));
        }

        let request_body = if !endpoint.body.fields.is_empty() {
            Some(Self::create_request_body(&endpoint.body))
        } else {
            None
        };

        let method = path_spec.method.as_str().to_lowercase();
        let path = path_spec.path.trim_matches('/');
        let operation_id = if path.is_empty() {
            method
        } else {
            format!("{}_{}", method, path.replace('/', "_"))
        };

        Operation {
            tags,
            summary: Some(endpoint.docs.clone()),
            operation_id: Some(operation_id),
            parameters,
            request_body,
            responses: Some(Self::create_default_responses()),
            deprecated: route.annotation.docs.as_ref().map(|d| d.deprecated),
            // security: Self::get_security_requirement(endpoint.annotation),
            ..Default::default()
        }
    }
    fn create_path_parameters(
        endpoint: &Endpoint,
        params: &[String],
    ) -> Vec<ObjectOrReference<Parameter>> {
        params
            .iter()
            .map(|param_name| {
                // Look for this parameter in the endpoint's path fields
                let path_field = endpoint.path.iter().find(|field| &field.name == param_name);

                ObjectOrReference::Object(Parameter {
                    name: param_name.clone(),
                    location: ParameterIn::Path,
                    description: Some(path_field.map_or(String::new(), |f| f.docs.clone())),
                    required: Some(true), // Path parameters are always required
                    deprecated: None,
                    allow_empty_value: None,
                    style: Some(ParameterStyle::Simple),
                    explode: None,
                    allow_reserved: None,
                    schema: Some(match path_field {
                        Some(field) => Self::create_schema_for_field(field),
                        None => Self::create_default_schema_for_path_param(),
                    }),
                    example: path_field.and_then(|f| f.default.clone()),
                    examples: BTreeMap::new(),
                    content: None,
                    extensions: BTreeMap::new(),
                })
            })
            .collect()
    }

    fn create_default_schema_for_path_param() -> ObjectOrReference<ObjectSchema> {
        ObjectOrReference::Object(ObjectSchema {
            schema_type: Some(SchemaTypeSet::Single(Type::String)),
            description: Some(String::new()),
            ..Default::default()
        })
    }

    fn create_query_parameter(field: &Field) -> ObjectOrReference<Parameter> {
        ObjectOrReference::Object(Parameter {
            name: field.name.clone(),
            location: ParameterIn::Query,
            description: Some(field.docs.clone()),
            required: Some(field.required),
            deprecated: None,
            allow_empty_value: None,
            style: Some(ParameterStyle::Form),
            explode: None,
            allow_reserved: None,
            schema: Some(Self::create_schema_for_field(field)),
            example: field.default.clone(),
            examples: BTreeMap::new(),
            content: None,
            extensions: BTreeMap::new(),
        })
    }

    fn create_request_body(body: &crate::ast::RequestBody) -> ObjectOrReference<RequestBody> {
        let mut properties = BTreeMap::new();
        let mut required = Vec::new();

        for field in &body.fields {
            properties.insert(field.name.clone(), Self::create_schema_for_field(field));
            if field.required {
                required.push(field.name.clone());
            }
        }

        let content_type = match body.kind {
            BodyKind::Json => "application/json",
            BodyKind::Form => "application/x-www-form-urlencoded",
        };

        let mut content = BTreeMap::new();
        content.insert(
            content_type.to_string(),
            MediaType {
                schema: Some(ObjectOrReference::Object(ObjectSchema {
                    properties,
                    required,
                    schema_type: Some(SchemaTypeSet::Single(Type::Object)),
                    ..Default::default()
                })),
                ..Default::default()
            },
        );

        ObjectOrReference::Object(RequestBody {
            content,
            required: Some(true),
            ..Default::default()
        })
    }

    fn create_schema_for_field(field: &Field) -> ObjectOrReference<ObjectSchema> {
        let mut schema = ObjectSchema {
            schema_type: Some(SchemaTypeSet::Single(match field._type {
                FieldType::Str => Type::String,
                FieldType::Number => Type::Number,
                FieldType::Bool => Type::Boolean,
                FieldType::Array => Type::Array,
            })),
            description: Some(field.docs.clone()),
            default: field.default.clone(),
            ..Default::default()
        };

        for validator in field.validators.iter() {
            if let Some(string_validator) = validator.downcast_ref::<StringValidator>() {
                if let Some(min_len) = string_validator.min_len {
                    schema.min_length = Some(min_len as u64);
                }
                if let Some(max_len) = string_validator.max_len {
                    schema.max_length = Some(max_len as u64);
                }
            }
            if let Some(in_validator) = validator.downcast_ref::<InValidator>() {
                schema.enum_values = in_validator.0.clone();
            }
        }

        ObjectOrReference::Object(schema)
    }

    fn create_default_responses() -> BTreeMap<String, ObjectOrReference<Response>> {
        let mut responses = BTreeMap::new();
        responses.insert(
            "200".to_string(),
            ObjectOrReference::Object(Response {
                description: Some("Successful operation".to_string()),
                ..Default::default()
            }),
        );
        responses
    }
}