diff --git a/README.md b/README.md index 500eb46..caf335c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Whether you're scaffolding a greenfield service from an OpenAPI contract, evolvi - **Host-adapter architecture** — the runtime core (`lib/`) is completely decoupled from I/O; any execution environment (Bun, Node, Deno, native Wasm) can be plugged in by implementing a thin host adapter - **Type-safe HTTP primitives** — `HttpMethod`, `RequestEnvelope`, `ResponseEnvelope`, and a precise typed error hierarchy (`AppError`, `DecodeError`) mean routing mistakes are caught at compile time, not at runtime - **In-memory test host** — `InMemoryHost` lets you drive your full routing and handler stack in unit tests without spinning up a real server or making network calls +- **Built-in API docs routes** — generated projects automatically expose Swagger UI at `/docs`, ReDoc at `/docs/redoc`, and the raw OpenAPI document at `/docs/openapi` - **Five focused CLI commands** — `init`, `generate`, `check`, `diff`, `doctor` — each doing exactly one thing, composable in scripts and CI pipelines --- diff --git a/main/generator.mbt b/main/generator.mbt index 61a76a1..0fe83fc 100644 --- a/main/generator.mbt +++ b/main/generator.mbt @@ -5,14 +5,18 @@ let generated_header = "// Code generated by mapi. DO NOT EDIT.\n" let user_header = "// User-owned file. Safe from regeneration.\n" ///| -fn generate_project(ir : ApiIR, module_name : String) -> Map[String, String] { +fn generate_project( + ir : ApiIR, + module_name : String, + openapi_spec_raw : String, +) -> Map[String, String] { let files = {} files["moon.mod.json"] = generate_moon_mod(module_name) files["src/generated/moon.pkg"] = generate_generated_pkg(module_name) files["src/generated/schemas.mbt"] = generate_schemas_file(ir) files["src/generated/operations.mbt"] = generate_operations_file(ir) files["src/generated/contract.mbt"] = generate_contract_file(ir) - files["src/generated/router.mbt"] = generate_router_file(ir) + files["src/generated/router.mbt"] = generate_router_file(ir, openapi_spec_raw) files["src/generated/client.mbt"] = generate_client_file(ir) files["src/handlers/moon.pkg"] = generate_handlers_pkg(module_name) for operation in ir.operations { @@ -194,10 +198,40 @@ fn generate_contract_file(ir : ApiIR) -> String { } ///| -fn generate_router_file(ir : ApiIR) -> String { +fn generate_router_file(ir : ApiIR, openapi_spec_raw : String) -> String { let builder = StringBuilder::new() + let escaped_openapi_spec = escape_moon_string(openapi_spec_raw) + let openapi_content_type = infer_openapi_content_type(openapi_spec_raw) builder.write_string(generated_header) builder.write_string("///|\n") + builder.write_string("fn docs_html_response(html : String) -> @lib.ResponseEnvelope {\n") + builder.write_string(" {\n") + builder.write_string(" status: 200,\n") + builder.write_string(" headers: { \"content-type\": \"text/html; charset=utf-8\" },\n") + builder.write_string(" body: Some(@utf8.encode(html)),\n") + builder.write_string(" }\n") + builder.write_string("}\n\n") + builder.write_string("///|\n") + builder.write_string("fn openapi_docs_response() -> @lib.ResponseEnvelope {\n") + builder.write_string(" {\n") + builder.write_string(" status: 200,\n") + builder.write_string(" headers: { \"content-type\": \"") + builder.write_string(openapi_content_type) + builder.write_string("\" },\n") + builder.write_string(" body: Some(@utf8.encode(\"") + builder.write_string(escaped_openapi_spec) + builder.write_string("\")),\n") + builder.write_string(" }\n") + builder.write_string("}\n\n") + builder.write_string("///|\n") + builder.write_string("fn swagger_ui_html() -> String {\n") + builder.write_string(" \"API Docs
Swagger UI (internal development platform) · ReDoc (open platform)
\"\n") + builder.write_string("}\n\n") + builder.write_string("///|\n") + builder.write_string("fn redoc_html() -> String {\n") + builder.write_string(" \"API Docs
ReDoc (open platform) · Swagger UI (internal development platform)
\"\n") + builder.write_string("}\n\n") + builder.write_string("///|\n") builder.write_string( "pub fn routes(_handlers : ApiHandlers) -> Array[@lib.Route] {\n", ) @@ -211,6 +245,27 @@ fn generate_router_file(ir : ApiIR) -> String { "\", handler: (_ctx, _req, _params) => { status: 501, headers: Map::new(), body: None } },\n", ) } + builder.write_string(" {\n") + builder.write_string(" http_method: @lib.HttpMethod::Get,\n") + builder.write_string(" pattern: \"/docs\",\n") + builder.write_string( + " handler: (_ctx, _req, _params) => docs_html_response(swagger_ui_html()),\n", + ) + builder.write_string(" },\n") + builder.write_string(" {\n") + builder.write_string(" http_method: @lib.HttpMethod::Get,\n") + builder.write_string(" pattern: \"/docs/redoc\",\n") + builder.write_string( + " handler: (_ctx, _req, _params) => docs_html_response(redoc_html()),\n", + ) + builder.write_string(" },\n") + builder.write_string(" {\n") + builder.write_string(" http_method: @lib.HttpMethod::Get,\n") + builder.write_string(" pattern: \"/docs/openapi\",\n") + builder.write_string( + " handler: (_ctx, _req, _params) => openapi_docs_response(),\n", + ) + builder.write_string(" },\n") builder.write_string(" ]\n") builder.write_string("}\n\n") builder.write_string("///|\n") @@ -226,6 +281,70 @@ fn generate_router_file(ir : ApiIR) -> String { builder.to_string() } +///| +fn infer_openapi_content_type(openapi_spec_raw : String) -> String { + let trimmed = openapi_spec_raw.trim() + if trimmed.has_prefix("{") || trimmed.has_prefix("[") { + "application/json" + } else if has_openapi_yaml_key(openapi_spec_raw) { + "application/yaml" + } else { + "text/plain; charset=utf-8" + } +} + +///| +fn has_openapi_yaml_key(raw : String) -> Bool { + for line in raw.split("\n") { + if line.to_string().trim_start().has_prefix("openapi:") { + return true + } + } + false +} + +///| +fn escape_moon_string(raw : String) -> String { + let builder = StringBuilder::new() + for ch in raw.to_array() { + match ch { + '\\' => builder.write_string("\\\\") + '"' => builder.write_string("\\\"") + '\n' => builder.write_string("\\n") + '\r' => builder.write_string("\\r") + '\t' => builder.write_string("\\t") + _ => { + let code = ch.to_int() + if code < 32 || code == 127 { + builder.write_string("\\u{") + builder.write_string(to_hex_byte(code)) + builder.write_string("}") + } else { + builder.write_char(ch) + } + } + } + } + builder.to_string() +} + +///| +fn to_hex_byte(value : Int) -> String { + let builder = StringBuilder::new() + builder.write_char(to_hex_digit(value / 16)) + builder.write_char(to_hex_digit(value % 16)) + builder.to_string() +} + +///| +fn to_hex_digit(value : Int) -> Char { + if value < 10 { + (value + '0'.to_int()).unsafe_to_char() + } else { + (value - 10 + 'a'.to_int()).unsafe_to_char() + } +} + ///| fn generate_client_file(ir : ApiIR) -> String { let builder = StringBuilder::new() diff --git a/main/main.mbt b/main/main.mbt index 0c9809a..dc4910a 100644 --- a/main/main.mbt +++ b/main/main.mbt @@ -44,7 +44,7 @@ fn run_command(args : Array[String], mode : CommandMode) -> Unit { } } let module_name = infer_module_name(config.output_dir) - let files = generate_project(ir, module_name) + let files = generate_project(ir, module_name, spec) write_project(files, config.output_dir, mode == Generate) catch { err => fail_with_message("Failed to write project: ", debug_string(err)) }