cargo-nidus 1.0.2

Command-line project generator and inspection tooling for Nidus applications.
use std::{collections::BTreeSet, path::Path};

use anyhow::Result;
use serde_json::{Value, json};

use crate::schema::discover_schemas;
use crate::{route_path::openapi_path_parameters, routes::discover_routes};

#[derive(Debug)]
pub(crate) struct OpenApiOptions {
    pub(crate) title: String,
    pub(crate) version: String,
}

impl Default for OpenApiOptions {
    fn default() -> Self {
        Self {
            title: "Nidus API".to_owned(),
            version: "0.1.0".to_owned(),
        }
    }
}

pub(crate) fn generate_openapi(root: &Path, options: &OpenApiOptions) -> Result<()> {
    let mut paths = serde_json::Map::new();
    let mut schema_names = BTreeSet::new();
    for route in discover_routes(root)? {
        let parameters = openapi_path_parameters(&route.path);
        let response_status = route.response_status.unwrap_or(200).to_string();
        let operation_id = operation_id(&route.method, &route.path);
        let entry = paths
            .entry(route.path)
            .or_insert_with(|| Value::Object(serde_json::Map::new()));
        if let Value::Object(methods) = entry {
            let mut operation = serde_json::Map::from_iter([
                ("operationId".to_owned(), json!(operation_id)),
                (
                    "responses".to_owned(),
                    json!({
                        response_status.clone(): {
                            "description": "Success"
                        }
                    }),
                ),
            ]);
            if let Some(summary) = route.summary {
                operation.insert("summary".to_owned(), json!(summary));
            }
            if !route.tags.is_empty() {
                operation.insert("tags".to_owned(), json!(route.tags));
            }
            if !route.guards.is_empty() {
                operation.insert("x-nidus-guards".to_owned(), json!(route.guards));
            }
            if !route.pipes.is_empty() {
                operation.insert("x-nidus-pipes".to_owned(), json!(route.pipes));
            }
            if route.validates {
                operation.insert("x-nidus-validates".to_owned(), json!(true));
            }
            if let Some(schema) = route.request_schema {
                schema_names.insert(schema.clone());
                operation.insert(
                    "requestBody".to_owned(),
                    json!({
                        "required": true,
                        "content": {
                            "application/json": {
                                "schema": schema_ref(&schema)
                            }
                        }
                    }),
                );
            }
            if let Some(schema) = route.response_schema {
                schema_names.insert(schema.clone());
                operation.insert(
                    "responses".to_owned(),
                    json!({
                        response_status: {
                            "description": "Success",
                            "content": {
                                "application/json": {
                                    "schema": schema_ref(&schema)
                                }
                            }
                        }
                    }),
                );
            }
            if !parameters.is_empty() {
                operation.insert(
                    "parameters".to_owned(),
                    json!(
                        parameters
                            .into_iter()
                            .map(|name| {
                                json!({
                                    "name": name,
                                    "in": "path",
                                    "required": true,
                                    "schema": {
                                        "type": "string"
                                    }
                                })
                            })
                            .collect::<Vec<_>>()
                    ),
                );
            }
            methods.insert(route.method, Value::Object(operation));
        }
    }

    let mut document = json!({
        "openapi": "3.1.0",
        "info": {
            "title": options.title,
            "version": options.version,
        },
        "paths": paths,
    });

    if !schema_names.is_empty() {
        let schemas = discover_schemas(root, &schema_names)?
            .into_iter()
            .collect::<serde_json::Map<_, _>>();
        document["components"] = json!({
            "schemas": schemas,
        });
    }

    println!("{}", document);
    Ok(())
}

fn schema_ref(schema: &str) -> Value {
    json!({
        "$ref": format!("#/components/schemas/{schema}")
    })
}

fn operation_id(method: &str, path: &str) -> String {
    let mut parts = vec![method.to_owned()];
    for segment in path.split('/') {
        if segment.is_empty() {
            continue;
        }
        if let Some(name) = segment
            .strip_prefix('{')
            .and_then(|value| value.strip_suffix('}'))
        {
            parts.push("by".to_owned());
            parts.push(identifier_segment(name));
        } else {
            parts.push(identifier_segment(segment));
        }
    }
    if parts.len() == 1 {
        parts.push("root".to_owned());
    }
    parts.join("_")
}

fn identifier_segment(segment: &str) -> String {
    let mut output = String::new();
    let mut previous_was_separator = true;
    for character in segment.chars() {
        if character.is_ascii_alphanumeric() {
            output.push(character.to_ascii_lowercase());
            previous_was_separator = false;
        } else if !previous_was_separator {
            output.push('_');
            previous_was_separator = true;
        }
    }
    if output.ends_with('_') {
        output.pop();
    }
    if output.is_empty() {
        "value".to_owned()
    } else {
        output
    }
}