nfw-core 0.1.1

Blazing fast fullstack framework powered by NestForge
Documentation
use std::collections::HashMap;
use std::path::Path;

use crate::openapi::spec::{
    MediaTypeObject, OpenApiSpec, OperationObject, ParameterLocation, ParameterObject,
    RequestBodyObject, ResponseObject, SchemaObject, ServerObject, TagObject,
};
use crate::routing::Route;

pub struct OpenApiGenerator {
    spec: OpenApiSpec,
}

impl OpenApiGenerator {
    pub fn new(title: &str, version: &str) -> Self {
        Self {
            spec: OpenApiSpec {
                info: crate::openapi::spec::InfoObject {
                    title: title.to_string(),
                    description: None,
                    version: version.to_string(),
                    contact: None,
                    license: None,
                },
                ..Default::default()
            },
        }
    }

    pub fn with_server(mut self, url: &str, description: Option<&str>) -> Self {
        self.spec.servers.push(ServerObject {
            url: url.to_string(),
            description: description.map(|s| s.to_string()),
        });
        self
    }

    pub fn with_description(mut self, description: &str) -> Self {
        self.spec.info.description = Some(description.to_string());
        self
    }

    pub fn with_contact(mut self, name: &str, email: &str, url: Option<&str>) -> Self {
        self.spec.info.contact = Some(crate::openapi::spec::ContactObject {
            name: Some(name.to_string()),
            email: Some(email.to_string()),
            url: url.map(|s| s.to_string()),
        });
        self
    }

    pub fn add_tag(&mut self, name: &str, description: Option<&str>) {
        self.spec.tags.push(TagObject {
            name: name.to_string(),
            description: description.map(|s| s.to_string()),
        });
    }

    pub fn add_schema(&mut self, name: &str, schema: SchemaObject) {
        self.spec
            .components
            .schemas
            .insert(name.to_string(), schema);
    }

    pub fn add_route(&mut self, route: &Route) {
        let path = route.path.clone();
        let operation = self.create_operation(route);

        let path_item = self.spec.paths.entry(path).or_default();

        match route.method.as_str() {
            "GET" => path_item.get = Some(operation),
            "POST" => path_item.post = Some(operation),
            "PUT" => path_item.put = Some(operation),
            "PATCH" => path_item.patch = Some(operation),
            "DELETE" => path_item.delete = Some(operation),
            "OPTIONS" => path_item.options = Some(operation),
            "HEAD" => path_item.head = Some(operation),
            _ => {}
        }
    }

    pub fn add_routes(&mut self, routes: &[Route]) {
        for route in routes {
            self.add_route(route);
        }
    }

    fn create_operation(&self, route: &Route) -> OperationObject {
        let mut params = Vec::new();

        for segment in &route.segments {
            match segment {
                crate::routing::RouteSegment::Dynamic(name) => {
                    params.push(ParameterObject {
                        name: name.clone(),
                        location: ParameterLocation::Path,
                        required: Some(true),
                        description: Some(format!("Dynamic parameter: {}", name)),
                        schema: SchemaObject::string(),
                    });
                }
                crate::routing::RouteSegment::CatchAll(name) => {
                    params.push(ParameterObject {
                        name: name.clone(),
                        location: ParameterLocation::Path,
                        required: Some(true),
                        description: Some(format!("Catch-all parameter: {}", name)),
                        schema: SchemaObject::string(),
                    });
                }
                crate::routing::RouteSegment::OptionalCatchAll(name) => {
                    params.push(ParameterObject {
                        name: name.clone(),
                        location: ParameterLocation::Path,
                        required: Some(false),
                        description: Some(format!("Optional catch-all: {}", name)),
                        schema: SchemaObject::string(),
                    });
                }
                crate::routing::RouteSegment::Static(_) => {}
            }
        }

        OperationObject {
            operation_id: Some(route.handler_name.clone()),
            summary: Some(format!("{} {}", route.method.as_str(), route.path)),
            description: None,
            tags: vec![self.extract_tag(&route.path)],
            parameters: params,
            request_body: None,
            responses: self.default_responses(),
            deprecated: false,
        }
    }

    fn extract_tag(&self, path: &str) -> String {
        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
        segments
            .first()
            .map(|s| s.to_string())
            .unwrap_or_else(|| "default".to_string())
    }

    fn default_responses(&self) -> HashMap<String, ResponseObject> {
        let mut responses = HashMap::new();

        responses.insert(
            "200".to_string(),
            ResponseObject {
                description: "Successful response".to_string(),
                content: {
                    let mut content = HashMap::new();
                    content.insert(
                        "application/json".to_string(),
                        MediaTypeObject {
                            schema: Some(SchemaObject::object(HashMap::new())),
                            example: None,
                        },
                    );
                    content
                },
                headers: HashMap::new(),
            },
        );

        responses.insert(
            "400".to_string(),
            ResponseObject {
                description: "Bad request".to_string(),
                content: HashMap::new(),
                headers: HashMap::new(),
            },
        );

        responses.insert(
            "404".to_string(),
            ResponseObject {
                description: "Not found".to_string(),
                content: HashMap::new(),
                headers: HashMap::new(),
            },
        );

        responses
    }

    pub fn add_request_body(&mut self, path: &str, method: &str, schema_name: &str) {
        if let Some(path_item) = self.spec.paths.get_mut(path) {
            let operation = match method.to_uppercase().as_str() {
                "GET" => &mut path_item.get,
                "POST" => &mut path_item.post,
                "PUT" => &mut path_item.put,
                "PATCH" => &mut path_item.patch,
                "DELETE" => &mut path_item.delete,
                _ => return,
            };

            if let Some(op) = operation {
                let mut content = HashMap::new();
                content.insert(
                    "application/json".to_string(),
                    MediaTypeObject {
                        schema: Some(SchemaObject::object(
                            self.spec
                                .components
                                .schemas
                                .get(schema_name)
                                .cloned()
                                .unwrap_or_default()
                                .properties
                                .unwrap_or_default(),
                        )),
                        example: None,
                    },
                );

                op.request_body = Some(RequestBodyObject {
                    description: Some(format!("Request body for {}", schema_name)),
                    required: true,
                    content,
                });
            }
        }
    }

    pub fn add_response(
        &mut self,
        path: &str,
        method: &str,
        status: &str,
        schema_name: &str,
        description: &str,
    ) {
        if let Some(path_item) = self.spec.paths.get_mut(path) {
            let operation = match method.to_uppercase().as_str() {
                "GET" => &mut path_item.get,
                "POST" => &mut path_item.post,
                "PUT" => &mut path_item.put,
                "PATCH" => &mut path_item.patch,
                "DELETE" => &mut path_item.delete,
                _ => return,
            };

            if let Some(op) = operation {
                let schema = self.spec.components.schemas.get(schema_name).cloned();

                let mut content = HashMap::new();
                if let Some(ref s) = schema {
                    content.insert(
                        "application/json".to_string(),
                        MediaTypeObject {
                            schema: Some(s.clone()),
                            example: None,
                        },
                    );
                }

                op.responses.insert(
                    status.to_string(),
                    ResponseObject {
                        description: description.to_string(),
                        content,
                        headers: HashMap::new(),
                    },
                );
            }
        }
    }

    pub fn build(self) -> OpenApiSpec {
        self.spec
    }

    pub fn to_json(&self) -> anyhow::Result<String> {
        serde_json::to_string_pretty(&self.spec)
            .map_err(|e| anyhow::anyhow!("Failed to serialize OpenAPI spec: {}", e))
    }

    pub fn to_yaml(&self) -> anyhow::Result<String> {
        serde_yaml::to_string(&self.spec)
            .map_err(|e| anyhow::anyhow!("Failed to serialize OpenAPI spec to YAML: {}", e))
    }

    pub fn write_json(&self, output_path: &Path) -> anyhow::Result<()> {
        let json = self.to_json()?;
        std::fs::write(output_path, json)?;
        tracing::info!("Written OpenAPI spec to {}", output_path.display());
        Ok(())
    }

    pub fn write_yaml(&self, output_path: &Path) -> anyhow::Result<()> {
        let yaml = self.to_yaml()?;
        std::fs::write(output_path, yaml)?;
        tracing::info!("Written OpenAPI spec to {}", output_path.display());
        Ok(())
    }
}

pub struct RouteToOpenApiConverter {
    generator: OpenApiGenerator,
}

impl RouteToOpenApiConverter {
    pub fn from_routes(title: &str, version: &str, routes: &[Route]) -> Self {
        let mut generator = OpenApiGenerator::new(title, version);

        for route in routes {
            generator.add_route(route);
        }

        Self { generator }
    }

    pub fn convert(mut self) -> OpenApiSpec {
        for (name, schema) in self.infer_schemas() {
            self.generator.add_schema(&name, schema);
        }

        self.generator.build()
    }

    fn infer_schemas(&self) -> Vec<(String, SchemaObject)> {
        vec![(
            "Error".to_string(),
            SchemaObject::object(
                vec![
                    ("code".to_string(), SchemaObject::string()),
                    ("message".to_string(), SchemaObject::string()),
                ]
                .into_iter()
                .collect(),
            ),
        )]
    }
}