Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
125 changes: 122 additions & 3 deletions main/generator.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(" \"<!doctype html><html><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>API Docs</title><link rel=\\\"stylesheet\\\" href=\\\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\\\"><style>html{box-sizing:border-box;overflow:auto}*,*:before,*:after{box-sizing:inherit}body{margin:0;background:#fafafa}</style></head><body><div style=\\\"padding:12px 16px;font-family:Arial,sans-serif;background:#111827;color:#fff\\\">Swagger UI (internal development platform) · <a href=\\\"/docs/redoc\\\" style=\\\"color:#93c5fd\\\">ReDoc (open platform)</a></div><div id=\\\"swagger-ui\\\"></div><script src=\\\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\\\"></script><script>window.onload=function(){window.ui=SwaggerUIBundle({url:'/docs/openapi',dom_id:'#swagger-ui'});};</script></body></html>\"\n")
builder.write_string("}\n\n")
builder.write_string("///|\n")
builder.write_string("fn redoc_html() -> String {\n")
builder.write_string(" \"<!doctype html><html><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>API Docs</title><style>body{margin:0;padding:0}header{padding:12px 16px;font-family:Arial,sans-serif;background:#111827;color:#fff}a{color:#93c5fd}</style></head><body><header>ReDoc (open platform) · <a href=\\\"/docs\\\">Swagger UI (internal development platform)</a></header><redoc spec-url=\\\"/docs/openapi\\\"></redoc><script src=\\\"https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js\\\"></script></body></html>\"\n")
builder.write_string("}\n\n")
builder.write_string("///|\n")
builder.write_string(
"pub fn routes(_handlers : ApiHandlers) -> Array[@lib.Route] {\n",
)
Expand All @@ -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")
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion main/main.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Loading