mrapids 0.1.7

Your OpenAPI, but executable
Documentation
use crate::core::parser::{UnifiedOperation, UnifiedSpec};
use anyhow::Result;
use std::fs;
use std::path::Path;

pub fn generate(
    spec: &UnifiedSpec,
    output_dir: &Path,
    with_tests: bool,
    with_validation: bool,
) -> Result<()> {
    // Create directory structure
    fs::create_dir_all(output_dir.join("src"))?;
    fs::create_dir_all(output_dir.join("src/routes"))?;
    fs::create_dir_all(output_dir.join("src/middleware"))?;
    fs::create_dir_all(output_dir.join("src/controllers"))?;

    if with_tests {
        fs::create_dir_all(output_dir.join("tests"))?;
    }

    // Generate main app.ts
    generate_app(spec, output_dir)?;

    // Generate routes
    generate_routes(spec, output_dir)?;

    // Generate middleware
    generate_middleware(spec, output_dir, with_validation)?;

    // Generate package.json
    generate_package_json(spec, output_dir)?;

    // Generate tsconfig.json
    generate_tsconfig(output_dir)?;

    // Generate tests if requested
    if with_tests {
        generate_tests(spec, output_dir)?;
    }

    Ok(())
}

fn generate_app(spec: &UnifiedSpec, output_dir: &Path) -> Result<()> {
    let content = format!(
        r#"/**
 * {} Express Server
 * Generated by MicroRapid
 */
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import {{ errorHandler }} from './middleware/errorHandler';
{}

const app = express();

// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({{ extended: true }}));

// Routes
{}

// Health check
app.get('/health', (req, res) => {{
  res.json({{ status: 'healthy', version: '{}' }});
}});

// Error handling
app.use(errorHandler);

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {{
  console.log(`Server running on port ${{PORT}}`);
}});

export {{ app }};
"#,
        spec.info.title,
        generate_route_imports(&spec),
        generate_route_uses(&spec),
        spec.info.version
    );

    fs::write(output_dir.join("src/app.ts"), content)?;
    Ok(())
}

fn generate_routes(spec: &UnifiedSpec, output_dir: &Path) -> Result<()> {
    // Group operations by path prefix
    let mut route_groups: std::collections::HashMap<String, Vec<&UnifiedOperation>> =
        std::collections::HashMap::new();

    for operation in &spec.operations {
        // Extract route group from path (e.g., /users/{id} -> users)
        let group = operation
            .path
            .split('/')
            .nth(1)
            .unwrap_or("default")
            .to_string();

        route_groups
            .entry(group)
            .or_insert_with(Vec::new)
            .push(operation);
    }

    // Generate router for each group
    for (group_name, operations) in route_groups {
        generate_router(&group_name, &operations, output_dir)?;
    }

    Ok(())
}

fn generate_router(name: &str, operations: &[&UnifiedOperation], output_dir: &Path) -> Result<()> {
    let mut route_handlers = String::new();

    for op in operations {
        route_handlers.push_str(&format!(
            r#"
router.{}('{}', async (req: Request, res: Response, next: NextFunction) => {{
  try {{
    // TODO: Implement {} logic
    res.json({{ message: 'Not implemented: {}' }});
  }} catch (error) {{
    next(error);
  }}
}});
"#,
            op.method.to_lowercase(),
            op.path.replace(&format!("/{}", name), ""),
            op.operation_id,
            op.operation_id
        ));
    }

    let content = format!(
        r#"/**
 * {} Routes
 * Generated by MicroRapid
 */
import {{ Router, Request, Response, NextFunction }} from 'express';

const router = Router();
{}

export default router;
"#,
        to_title_case(name),
        route_handlers
    );

    fs::write(output_dir.join(format!("src/routes/{}.ts", name)), content)?;
    Ok(())
}

fn generate_middleware(
    _spec: &UnifiedSpec,
    output_dir: &Path,
    with_validation: bool,
) -> Result<()> {
    // Error handler
    let error_handler = r#"import { Request, Response, NextFunction } from 'express';

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  console.error(err.stack);
  
  const status = (err as any).status || 500;
  const message = err.message || 'Internal Server Error';
  
  res.status(status).json({
    error: {
      message,
      status,
    },
  });
};
"#;

    fs::write(
        output_dir.join("src/middleware/errorHandler.ts"),
        error_handler,
    )?;

    if with_validation {
        // Add validation middleware
        let validator = r#"import { Request, Response, NextFunction } from 'express';
import { validationResult } from 'express-validator';

export const validate = (req: Request, res: Response, next: NextFunction) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};
"#;

        fs::write(output_dir.join("src/middleware/validator.ts"), validator)?;
    }

    Ok(())
}

fn generate_package_json(spec: &UnifiedSpec, output_dir: &Path) -> Result<()> {
    let content = format!(
        r#"{{
  "name": "{}",
  "version": "{}",
  "description": "{}",
  "main": "dist/app.js",
  "scripts": {{
    "dev": "ts-node-dev --respawn --transpile-only src/app.ts",
    "build": "tsc",
    "start": "node dist/app.js",
    "test": "jest"
  }},
  "dependencies": {{
    "express": "^4.18.2",
    "cors": "^2.8.5",
    "helmet": "^7.1.0"
  }},
  "devDependencies": {{
    "@types/express": "^4.17.21",
    "@types/node": "^20.10.0",
    "@types/cors": "^2.8.17",
    "typescript": "^5.3.0",
    "ts-node-dev": "^2.0.0",
    "@types/jest": "^29.5.0",
    "jest": "^29.7.0",
    "ts-jest": "^29.1.0",
    "supertest": "^6.3.0",
    "@types/supertest": "^2.0.16"
  }}
}}
"#,
        to_kebab_case(&spec.info.title),
        spec.info.version,
        spec.info.description.as_deref().unwrap_or("")
    );

    fs::write(output_dir.join("package.json"), content)?;
    Ok(())
}

fn generate_tsconfig(output_dir: &Path) -> Result<()> {
    let content = r#"{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}
"#;

    fs::write(output_dir.join("tsconfig.json"), content)?;
    Ok(())
}

fn generate_tests(_spec: &UnifiedSpec, output_dir: &Path) -> Result<()> {
    let content = r#"// Test setup
import { app } from '../src/app';
import request from 'supertest';

describe('API Tests', () => {
  test('GET /health', async () => {
    const res = await request(app).get('/health');
    expect(res.statusCode).toBe(200);
    expect(res.body).toHaveProperty('status', 'healthy');
  });
});
"#;

    fs::write(output_dir.join("tests/api.test.ts"), content)?;
    Ok(())
}

// Helper functions
fn generate_route_imports(spec: &UnifiedSpec) -> String {
    let mut imports = vec![];
    let mut seen = std::collections::HashSet::new();

    for op in &spec.operations {
        let group = op.path.split('/').nth(1).unwrap_or("default").to_string();

        if seen.insert(group.clone()) {
            imports.push(format!("import {} from './routes/{}'", group, group));
        }
    }

    imports.join(";\n")
}

fn generate_route_uses(spec: &UnifiedSpec) -> String {
    let mut uses = vec![];
    let mut seen = std::collections::HashSet::new();

    for op in &spec.operations {
        let group = op.path.split('/').nth(1).unwrap_or("default").to_string();

        if seen.insert(group.clone()) {
            uses.push(format!("app.use('/{0}', {0})", group));
        }
    }

    uses.join(";\n")
}

fn to_title_case(s: &str) -> String {
    s.split('_')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn to_kebab_case(s: &str) -> String {
    let mut result = String::new();
    let mut prev_is_lower = false;

    for ch in s.chars() {
        if ch.is_uppercase() && prev_is_lower {
            result.push('-');
        }
        result.push(ch.to_lowercase().next().unwrap());
        prev_is_lower = ch.is_lowercase();
    }

    result
}