A spec-driven code generator for MoonBit — ← goctl.
Documentation · Quick start · Commands · Library API · mooncakes
Moved on mooncakes from
Lfan-ke/moonctltomoonbitstack/moonctl.
moonctl parses a .api service spec and emits compilable moonapi scaffolding — the role goctl plays for Go. It's pure logic (parse → generate text), so it has no runtime dependencies and runs on every backend.
mctl serve builds a package and runs it. With --reload it does so again whenever a source changes, which is what uvicorn --reload does for a language that can re-import its modules:
$ mctl serve --reload # the package in this directory
$ mctl serve src/main --reload --reload-dir src # watch only src
$ mctl serve --reload -- --port 9000 # what follows -- goes to the server
Sources are .mbt and .mbti files, moon.pkg and moon.mod, looked at every --reload-delay seconds (0.25, as in uvicorn) under each --reload-dir (this directory by default). The server gets a graceful stop before the build, since Windows will not overwrite a running executable. A build that fails shows its errors and waits for the next save. Stopping mctl stops the server.
Given this .api spec:
service greet {
get /ping ping "health check"
get /users/:id get_user
post /users create_user "create a user"
}
generate(parse(spec)) emits:
// Code generated by moonctl. DO NOT EDIT.
///|
/// Build the greet application with its routes wired to handlers.
pub fn build_app() -> @moonapi.App {
let app = @moonapi.App::new()
app.get("/ping", ctx => ping(ctx), summary="health check")
app.get("/users/:id", ctx => get_user(ctx))
app.post("/users", ctx => create_user(ctx), summary="create a user")
app
}
///|
/// `get /ping`.
pub fn ping(_ctx : @moonapi.Context) -> @moonasgi.Response {
@moonapi.text(200, "TODO: ping")
}
// ... one stub per handlerEach route registers its handler through an arrow closure. moonapi's ApiHandler is
a raising fn-type, and under moonc 0.10.5 a pure named function no longer coerces to
one; a closure infers the raise effect, so both a stub that never raises and a
filled-in handler that raises an HttpException register unchanged.
The generated output is verified to compile against real moonapi + moonasgi — a code generator whose output doesn't build isn't a code generator.
An @server( … ) block annotates the service block under it, the way goctl groups handlers:
@server (
group: user
prefix: /api/v1
jwt: Auth
middleware: Log,Trace
)
service greet {
@doc "log a user in"
@handler login
post /login (LoginReq) returns (LoginResp)
}
Every route in the block carries the group. Its path is emitted with the prefix applied, the OpenAPI document tags the operation user, and the scaffold marks the block with what the spec asked for — a group's jwt and middleware have no moonapi call to emit them into, so they are stated rather than dropped:
pub fn build_app() -> @moonapi.App {
let app = @moonapi.App::new()
// @server group: user, prefix: /api/v1, jwt: Auth, middleware: Log,Trace
app.post("/api/v1/login", ctx => login(ctx), summary="log a user in")
app
}
///|
/// `post /api/v1/login` of group user.
pub fn login(_ctx : @moonapi.Context) -> @moonasgi.Response {
@moonapi.text(200, "TODO: login")
}group, prefix, jwt, middleware, maxBytes, timeout and signature are read into a Group; any other annotation the block carried is kept in Group::extra, for a template or plugin to use.
A file may hold several service blocks. Each one is a service of its own: its routes stay together, and the generated route table registers them one service at a time, in the order the spec declared them.
service catalog {
@handler list
get /items
@handler create
post /items (CreateReq) returns (ItemResp)
}
service admin {
@handler purge
delete /items/:id
}
pub fn register_catalog(app : @moonapi.App, svc : @svc.ServiceContext) -> @moonapi.App { … }
pub fn register_admin(app : @moonapi.App, svc : @svc.ServiceContext) -> @moonapi.App { … }
pub fn register_handlers(app : @moonapi.App, svc : @svc.ServiceContext) -> @moonapi.App {
let app = register_catalog(app, svc)
register_admin(app, svc)
}Two blocks declaring the same name are one service: that is how a spec splits a service so each part gets its own @server annotations. Spec::services lists the blocks; Spec::routes is the same routes flattened in file order, for the generators that do not care which block a route came from.
A field's back-tick tag says where its value comes from and what it has to look like, the way goctl borrowed it from Go:
type ListReq {
Region string `path:"region"`
Page int `form:"page,default=1,range=[1:100]"`
Size int `form:"size,optional"`
Trace string `header:"X-Trace"`
Sort string `json:"sort,options=asc|desc"`
}
json: reads out of the request body, form: off the query string, path: out of a URL segment and header: off a request header — so the OpenAPI document gives page as a query parameter, region as a path one under the name its tag chose, and keeps only the body-bound fields as schema properties. The options after the wire name are read too: optional (and Go's omitempty) keeps a field out of required, and default=, options= and range= become a default, an enum and minimum/maximum in the document — plus the code that enforces them, emitted beside the struct:
pub fn ListReq::with_defaults(self : ListReq) -> ListReq { … } // fills in every default=
pub fn ListReq::check(self : ListReq) -> String? { … } // the first field that failsThe generated handler fills the request type in from exactly the places its tags name, and hands it to the logic:
pub fn createhandler(
svc : @svc.ServiceContext,
ctx : @moonapi.Context,
) -> @moonasgi.Response {
let req = match @types.bind_create_req(ctx) {
Ok(req) => req
Err(why) => return @moonapi.text(400, why)
}
match @logic.createlogic(svc, req) {
Ok(reply) => @moonapi.json(200, ToJson::to_json(reply)),
Err(why) => @moonapi.text(501, why),
}
}bind_create_req is generated beside the struct, in internal/types: a path field comes from the URL segment, form from the query string, header from the request, and everything else from the JSON body. It returns the first thing it could not read — missing header "X-Trace", form "page": want Int32 — so a bad request answers 400 with the field named instead of reaching the logic half-filled. A field declared optional (or written note?: string) stays absent rather than failing the request.
A route that declares returns (T) gets a logic function returning Result[T, String]: the handler writes T as JSON, or answers 501 with the message while the body is still TODO. A route with no response type keeps the plain @moonasgi.Response shape, which is what a handler writing its own status and body wants.
A block can inline another by naming it on a line of its own (Base), and a nested Inner { … } becomes a type of its own named OuterInner — MoonBit has neither embedding nor anonymous structs, so an inlined block's fields are written out where they belong. A field is spelled snake_case in every generated MoonBit name, because UserName is not a MoonBit struct field; json_name is the wire side of the same field.
mctl gen api writes what goctl writes — a layered project, not a file:
$ mctl gen api greet.api --dir out --style go_zero
out/moon.mod out/internal/types/types.mbt
out/moon.pkg out/internal/handler/routes.mbt
out/greet.mbt out/internal/handler/user/login_handler.mbt
out/etc/greet.yaml out/internal/logic/user/login_logic.mbt
out/internal/config/config.mbt out/internal/middleware/log_middleware.mbt
out/internal/svc/service_context.mbtA grouped route's handler and logic go under the group's own directory, an ungrouped one's sit beside the routes, and every directory gets the moon.pkg that makes it a MoonBit package. --flat writes the single <file>.mbt earlier versions did.
What it writes is what moon fmt writes. After a generation, mctl runs moon fmt over the packages it just wrote, so a freshly generated project needs no reformatting pass before its first commit: line wrapping, trailing commas and the shape of a rule(...) in moon.mod are all already the formatter's. The library layer stays pure text — generate_tree returns what it computed — and the formatting is the binary's step, so it needs moon on PATH (without it, generation still writes every file and says so). CI proves it: scripts/check_scaffold_fmt.sh scaffolds one project of every kind, formats them, and fails if the formatter changed a byte.
Because the formatter works per package, a regeneration also formats the author's files in the packages it wrote. That is the same style moon fmt imposes on any MoonBit project, and it only ever moves whitespace.
Run it again and your code survives. Only the two files moonctl owns — internal/types/types.mbt and internal/handler/routes.mbt, both of which must follow the spec — are rewritten. Everything else is written once and then left alone, because that is where the handler and logic bodies you filled in live. A route added to the spec since the last run still gets its stubs; the ones already on disk are not touched.
The same rule is available to a library caller, which is where it is decided:
let tree = @moonctl.generate_tree(spec, style~, dir~) // -> [TreeFile { path, content, regen }]
let write = @moonctl.tree_plan(tree, p => on_disk(p)) // -> the files to actually write--style is goctl's naming template, not a list of cases: <before>GO<through>ZERO<after>, where the casing of the GO marker spells the first word, ZERO's spells every later one, and what stands between the markers stands between the words.
| template | welcome_to_go_zero becomes |
|---|---|
gozero (default) |
welcometogozero |
goZero |
welcomeToGoZero |
go_zero |
welcome_to_go_zero |
Go#zero |
Welcome#to#go#zero |
Words split on _ and before each capital. A template missing a marker (go, zero) or casing one of them any other way (gOZero, goZEro) is refused with a StyleError rather than silently taken for something else. The style names every file of the tree and the handler, logic and middleware entry points inside them; the names the spec itself chose — its type blocks and their fields — keep their spelling, since a MoonBit type name has to keep its capital.
A spec can import others, singly or in a group, and each path is read relative to the file that wrote it:
import "user.api"
import (
"shared/base.api"
"../common.api"
)
The imported types, routes and @server groups are merged in ahead of the importing file's own. A file is read once however many times it is imported, so a cycle terminates, and an import naming a file that is not there is reported against the file that asked for it rather than silently generating half a program.
Reading files is the caller's job — the library stays pure and all-backend — so it comes in two halves: deps says which files a source needs, and parse_all merges the ones you collected.
let spec = @moonctl.parse_all(source, files, from="spec/greet.api")parse raises a SpecError naming the line it could not read — a misspelt verb or annotation, a block left open, a field with no type, a route no @handler names:
@moonctl.parse("service greet {\n gett /ping ping\n}")
// SpecError — line 2: unknown verb "gett"Without that, a one-character typo generates a program that is quietly missing a route. parse_lenient skips those lines instead and returns whatever the spec does describe, for a spec that is still being written.
The layout follows goctl's: the spec language on its own, then one package per thing generated from it. The root re-exports the lot, so @moonctl.parse works as well as @api.parse.
| Package | What | goctl |
|---|---|---|
api |
the .api language: types, routes, tags, imports |
api/parser, api/spec |
gen |
the moonapi service a spec describes, and the validation its tags ask for | api/gogen |
template |
the template engine, Go text/template syntax and semantics |
text/template |
style |
--style naming templates |
config |
proto |
moonrpc messages, codecs and handler tables from .proto |
rpc |
doc |
OpenAPI and Swagger documents | api/docgen |
gql |
a moongql schema | — |
model |
moonorm models from a spec, from DDL, or from a live database | model |
agent |
a moonkoog agent | — |
plugin |
the plugin protocol | plugin |
scaffold |
project skeletons and the layered trees | api new, rpc new, gql new, mcp new, docker, kube |
As a library:
let spec = @moonctl.parse(source) // -> Spec { service, routes, types, info, groups, imports }
let code = @moonctl.generate(spec) // -> compilable moonapi scaffold (String)
let tree = @moonctl.generate_tree(spec) // -> the layered project, file by fileAs the mctl command-line tool (native binary):
$ mctl gen api greet.api # -> a moonapi project tree (etc/, internal/…)
$ mctl gen api greet.api --style go_zero --dir out
$ mctl gen api greet.api --flat # -> greet.mbt (one file, as before)
$ mctl gen api greet.api --home my/templates --dir out # -> through your own template library
$ mctl gen api greet.api --remote https://… --branch main --dir out
$ mctl gen rpc greet.proto # -> a moonrpc project tree (pb/, internal/…, client/)
$ mctl gen rpc greet.proto --flat # -> greet_grpc.mbt (messages, codecs, handler tables)
$ mctl gen gql greet.api # -> a moongql tree, the types as `#gql.type`
$ mctl gen gql greet.api --flat # -> greet_schema.mbt (the code-first schema builder)
$ mctl gen mcp greet.api # -> an MCP server whose tools are the routes
$ mctl gen model user.api # -> a moonorm tree, the tables as `#orm` annotations
$ mctl gen model user.api --flat # -> user_model.mbt (the hand-built model + migration)
$ mctl gen crud schema.sql # -> a moonorm tree with the typed CRUD helpers
$ mctl gen crud schema.sql --flat # -> schema_model.mbt (the hand-built model + typed CRUD)
$ mctl gen doc greet.api # -> greet_openapi.json + greet_swagger.html
$ mctl model datasource sqlite:shop.db # -> datasource_model.mbt (read a live DB's schema)
$ mctl api new blog # scaffold a moonapi service project under blog/
$ mctl rpc new echo # scaffold a moonrpc service project under echo/
$ mctl gql new books # scaffold a moongql service project under books/
$ mctl model new account # scaffold a moonorm data-layer project under account/
$ mctl docker greet # -> Dockerfile + .dockerignore
$ mctl kube greet # -> deploy/deployment.yaml + deploy/service.yaml
$ mctl plugin ./my-plugin greet.api # run an external plugin over the parsed spec
$ mctl api format greet.api # canonical spelling on stdout (--write for in place)
$ mctl api validate greet.api # findings, then "N error(s), M warning(s)"
$ mctl api doc greet.api # -> greet_api.md (Markdown reference)
$ mctl api ts greet.api # -> greet_client.ts (typed client)
$ mctl api dart greet.api # -> greet_client.dart
$ mctl api kt greet.api # -> greet_client.kt
$ mctl api java greet.api # -> GreetClient.java
$ mctl migrate greet.api # -> greet_migrate.sql (one table per type)
$ mctl model mysql datasource root@tcp(db)/shop # -> mysql_model.mbt
$ mctl model pg datasource postgres://u:p@db/shop # -> pg_model.mbt
$ mctl model mongo greet.api # -> greet_mongo.mbt (documents + repositories)
$ mctl model cache greet.api # -> greet_cache.mbt (cache-aside CRUD)
$ mctl gateway greet.proto # -> greet_gateway.mbt (HTTP front door)
$ mctl quickstart blog # scaffold an api project, then say what to run
$ mctl template list # the bundled template library
$ mctl upgrade 0.13.0 # move the moonbitstack pins in moon.mod
$ mctl config set branch release # edit ~/.mctl/config
$ mctl env # what mctl resolved, and from where
$ mctl completion bash # a shell completion script
$ mctl bug "api format eats my comment" # a report pre-filled with the environmentgenerate works as a synonym for gen, and rpc for proto.
Like goctl, mctl is the code generator for the whole stack, not just the web
tier. Two more front-ends feed the sibling libraries:
-
.proto→moonrpc:parse_protoreads a proto3 file (service/rpc/message, includingstream,repeated, andmap<K, V>);generate_grpcemits a struct permessage, a@moonrpc.Methoddescriptor per RPC (its gRPC:pathis/package.Service/Method), a<Service>Serverhandler-registration struct (one synchronous-core handler per RPC, returning its reply or a gRPC@moonrpc.Status), and a<service>_methods()listing. -
.apitype→moonorm:generate_modelturns each storabletypeblock into a model struct, a<table>_model : @moonorm.Model[T](declared columns — anidcolumn is the primary key — plus thefrom_rowdecoder andto_columnsprojection that MoonBit can't synthesise for want of reflection), a<table>_table : @moonorm.Tabledescriptor, and a<table>_up/<table>_downmigration pair (create the table idempotently, drop it — the explicit stand-in for an Alembic revision). A block with a non-storable field (a slice, map, or nested message) still gets its struct and a plainTabledescriptor. The output compiles againstmoonorm+moondb, so a consumer imports both.
Both outputs are verified to compile against the real moonrpc / moonorm
in a scratch consumer project (moon check → rc 0), the same guarantee the
moonapi scaffold carries.
let stub = @moonctl.generate_grpc(@moonctl.parse_proto(proto_src)) // -> moonrpc stub
let model = @moonctl.generate_model(@moonctl.parse(api_src)) // -> moonorm model + migrationgoctl model mysql ddl reads a .sql schema and generates a model with typed
CRUD; parse_ddl + generate_crud are the MoonBit version. parse_ddl reads
CREATE TABLE statements — column types, column- and table-level primary keys,
NOT NULL, and DEFAULT clauses — and generate_crud emits, per table, the
record struct, a @moonorm.Model, an up/down migration pair, and typed CRUD:
<table>_insert and <table>_all always, plus <table>_find_by_id,
<table>_update, and <table>_delete_by_id when the table has a single-column
primary key. SQL types map onto the @moondb.Value set — INTEGER/BIGINT →
Int/Int64, TEXT → String, REAL → Double, BLOB → Bytes,
BOOLEAN → Bool. Every generated statement is parameterised, so a CRUD call
binds its id and columns rather than splicing them.
let code = @moonctl.generate_crud_from_ddl(sql_src) // -> moonorm model + CRUD (String)The output is verified to compile against the real moonorm + moondb in a
scratch consumer (moon check → rc 0).
goctl model … datasource connects to a running database, reads its schema, and
generates models. mctl model datasource <dsn> is the MoonBit version. A SQLite DSN
reads sqlite_master + PRAGMA table_info through the FFI driver; a PostgreSQL DSN
reads information_schema over the async wire protocol. Both produce the same neutral
ReflectedColumn list, which folds into the same generate_crud pipeline the .sql
front end uses, so a live schema and a .sql file yield an identical model.
$ mctl model datasource sqlite:shop.db
$ mctl model datasource postgres://user:pass@localhost:5432/shopDSN parsing (parse_dsn) and the reflected-column → model fold
(tables_from_reflection / generate_crud_from_reflection) are pure, all-backend
core in the library; the schema reading lives in the native reflect sub-package,
so the library itself stays dependency-free. The pipeline is verified end to end
against a real in-memory SQLite database (create tables → reflect → generate → the
generated models compile against moonorm + moondb).
The model and GraphQL generators write the declaration, and the library writes the plumbing. gen model and gen crud emit #orm.table/#orm.column structs; the orm rule in moon.mod runs moonorm's generator over them at build time and produces T::model() — the columns, the row decoder and the insert projection. gen gql emits #gql.type structs and #gql.query/#gql.mutation root fields; the gql rule produces register(schema, resolvers).
Both come with the module they need, because an annotation is only a declaration inside a module that carries its rule. --flat still writes the hand-built single file, for a project with no module of its own.
api new, rpc new, gql new and mcp new write a service that runs as it stands: moon test --target native passes, moon run --target native . serves it on port 12000.
$ mctl api new blog # moonapi on mooncat
$ mctl rpc new echo # moonrpc, with a typed client
$ mctl gql new books # moongql on mooncat
$ mctl mcp new notes # moonmcp over HTTP, or stdio with -- --stdio
$ mctl model new account # a moonorm data layer
$ mctl docker greet # Dockerfile (two-stage native build) + .dockerignore
$ mctl kube greet # deploy/deployment.yaml + deploy/service.yamlThe four services share goctl's layout. Files marked DO NOT EDIT belong to mctl and are rewritten by the matching gen command; everything else is seeded once and then belongs to the author.
| Path | api | rpc | gql |
|---|---|---|---|
<name>.mbt |
serve the app on mooncat | serve the handler tables on GrpcServer |
build the schema and serve graphql_app |
etc/, internal/config |
✓ | ✓ | ✓ |
internal/svc |
✓ | ✓ | — the resolvers are free functions, so there is no context to pass |
| generated | internal/types, internal/handler/routes.mbt |
pb/, internal/server, client/<name>client.mbt |
internal/schema/schema.g.mbt |
| the author's | internal/handler/*, internal/logic/* |
internal/logic/* |
internal/schema/schema.mbt |
| build rule | — | — | dev_build(rule: "gql", …) in internal/schema/moon.pkg |
| test | the routes through TestClient |
Ping over a real socket |
{ __typename } through the built schema |
Every client call comes in two forms. A unary RPC is one function; a server-streaming RPC also gets <call>_take, which reads count messages and returns as soon as they are on the wire instead of waiting for the server to close the stream — the form a subscription that never ends needs:
let client = @client.CatalogClient::new(@net.Channel::connect("127.0.0.1", 12000))
let page = client.list(@pb.ListReq::{ page: 1 }) // every message, then return
let first = client.list_take(@pb.ListReq::{ page: 1 }, 1) // one message, at onceFor every message, pb/ has encode and decode in protobuf wire form, checked against Google's runtime in both directions. Every service gets a record with one function per RPC and handlers(), its RPCs keyed by :path for GrpcServer. A request that does not decode ends the call with INVALID_ARGUMENT.
Every generated moon.mod imports the libraries at the versions in pins. On every push, CI scaffolds each kind of project, runs its tests against those versions, and regenerates over an rpc scaffold to check that the author's files survive. A project that wants a later release edits its own moon.mod.
let files = @moonctl.scaffold_rpc("echo") // -> Array[GenFile] to write
let tree = @moonctl.generate_rpc_tree(proto) // -> Array[TreeFile], each marked Always or OnceLike goctl api plugin, mctl plugin <exe> <file.api> drives an out-of-tree
generator off the same parsed spec the built-in generators use, and speaks goctl's
protocol: mctl runs <exe> with whatever arguments the invocation carried and
writes it one JSON object on stdin — {Api, ApiFilePath, Style, Dir}, the parsed
spec plus the file it came from, the --style template and the output directory,
which is what tells a plugin where to write and how to spell what it writes. The
plugin prints the files it wants written — a JSON {"files": [{"path", "content"}, …]} reply — on its stdout, which mctl writes to disk. The wire shapes are
plugin_request (request) and parse_gen_files (reply); a plugin written in
MoonBit reads its input with plugin_from_json (or spec_from_json, which
unwraps the envelope for a plugin that wants nothing but the spec) and writes its
output with gen_files_to_json. cmd/demo-plugin is a working example that emits
a route listing.
let (bin, argv) = @moonctl.plugin_argv("my-plugin -flag value") // what to run
let req = @moonctl.plugin_request(spec, api_file_path~, style~, dir~) // its stdin
let files = @moonctl.parse_gen_files(@json.parse(out)) // the plugin's stdout -> files to writeA spec that parses can still be wrong: a route pointing at a type nobody declared,
two handlers under one name, a :id segment with no field to fill it. validate is
the second pair of eyes, and every finding names the rule that caught it, so a
toolchain can turn one class of finding off without turning the rest off.
$ mctl api validate greet.api
error [unknown-type] POST /users: request type "CreateReq" is not declared
warning [path-param] GET /users/:id: `:id` has no field named "id" in GetReq, so nothing fills it
mctl: greet.api: 1 error(s), 1 warning(s)format is the canonical spelling of the same file — one route and one type per
line, tags where they were, comments kept — so a spec reviews as a diff instead of
as a re-indent. It is idempotent, which is what makes it safe to run over a whole
repository:
let findings = @moonctl.validate_source(source) // Array[Diagnostic]
let ok = @moonctl.has_errors(findings) == false
let canonical = @moonctl.format(source) // format(format(x)) == format(x)mctl api ts|dart|kt|java reads the same spec and writes a typed client, one file
per language. The request type of a route becomes a model, its path: / form: /
header: fields become parameters (the body and the path segments leading, the
optional ones last), its response type becomes the return type, and a non-2xx answer
raises the client's own error. The generator lives in src/client and shares one
reading of the spec across four emitters, so a route is spelled the same way in all
four.
| Language | File | Ports the caller fills in |
|---|---|---|
| TypeScript | greet_client.ts |
Fetch (defaults to the global fetch), token |
| Dart | greet_client.dart |
Send, token |
| Kotlin | greet_client.kt |
Transport + Codec |
| Java | GreetClient.java |
Transport + Codec, nested in the class |
Transport is a port on purpose: a generated client compiles with no third-party
dependency, and runs over fetch, dio, Ktor or a test double without editing the
generated file. TypeScript and Dart serialize with the JSON their standard library
already has; Kotlin and Java take a Codec (encode(value) -> String,
decode(text) -> Map), so the project's existing kotlinx.serialization, Gson or
Jackson does the JSON and the client forces no fourth library into the build.
Each generated client was compiled once with its own compiler as the acceptance check:
$ tsc --strict --noEmit --target es2022 --lib es2022,dom greet_client.ts
$ dart analyze greet_client.dart
$ kotlinc greet_client.kt -d greet.jar
$ javac -Xlint:all -d out GreetClient.javamctl api doc writes the human-facing counterpart to the OpenAPI document: the
route table, the groups, then one table per type saying where each field is bound
and what the tag constrains it to.
mctl migrate reads the type blocks as tables — a column per field, the id
field as the primary key, optional as NULL, and a list or a nested type as
JSON — and writes <service>_migrate.sql. It is a starting point, and says so in
its header: a spec knows the shape of a value, not its indexes, its uniqueness or
its foreign keys.
let sql = @moonctl.generate_migrations(spec)A document store is not a table with a different name, and a Mongo driver is not a
dependency generated code should have to carry. So mctl model mongo writes each
type block as a document — a struct with #derive(FromJson, ToJson), so a value
round-trips without a hand-written field walker — and a typed repository over a
three-call port:
pub(all) struct Collection {
name : String
run : (MongoOp, Json, Json) -> Json raise MongoError // op, filter, document
}A driver binds run to insertOne / findOne / find / replaceOne /
deleteOne, and every repository is typed from there: insert, all, and — when
the spec has a field to look a record up by — find, replace and delete. A
nested type nests that type's document, so owner: User is stored as a UserDoc.
The generated file needs moonbitlang/core/json and nothing else.
mctl gateway turns a .proto into an HTTP gateway: a route table, the status
codes a client cares about (200, 400 for a body that is not JSON, 404 for a
path no route answers, 502 for an upstream failure), and a Caller port that
takes a gRPC method's full path and a request document. Routes default to
POST /<Service>/<Method> — generated before anyone has decided on URLs — and come
from a sibling mapping file when there is one:
# greet.routes
post /v1/users /shop.UserService/Create
get /v1/stock /shop.StockService/Count
A line naming a method the .proto does not declare is reported and skipped, and a
streaming RPC is left out of the table with a comment saying which one, because an
HTTP response is one body with one end.
mctl model cache writes the layer in front of a table's CRUD: read-through on
find, invalidate on write and on evict. The port is generic, so a cache holds
records rather than the text they serialise to, and an in-memory implementation is
a Map — which is what the generated memory_cache is, and what a test drives the
layer with.
A broken cache never fails a request: a failed read is a miss and a failed invalidation is swallowed, because a cache that can take a service down is worse than no cache at all.
The command surface is data, not code: src/cli holds one table that mctl help
prints, mctl completion completes from and the README lists, so a new command is
completable and documented by being added once.
| Command | What it does |
|---|---|
mctl help |
the command table, with the flags every generator shares |
mctl completion <shell> |
a completion script for bash, zsh, fish or pwsh, generated from the table |
mctl template list / show <name> / init <dir> |
the bundled template library, what one entry produces, and a starter library to edit |
| `mctl config list | get |
mctl env |
what mctl resolved — home, remote, branch, style — and where from |
mctl bug [title] |
a bug report pre-filled with the environment |
mctl upgrade [version] |
move the moonbitstack/* pins in moon.mod, touching nothing else |
mctl quickstart <name> |
scaffold an api project, then print what to run next |
mctl migrate <file.api> |
the SQL a spec's types imply |
| `mctl model mysql | pg datasource ` |
mctl model mongo <file.api> |
documents and typed repositories over a collection port |
mctl model cache <file.api> |
cache-aside CRUD: read-through, then invalidate on write |
mctl gateway <file.proto> |
an HTTP gateway over the proto's RPCs, routes from <stem>.routes when present |
The template library is reached three ways, as in goctl: the bundled defaults, a
--home <dir> of your own, or --remote <url> --branch <name> for a shared one.
A library is a directory of files with {{.…}} holes in them, at the same paths the
catalogue names — api/service/moon.mod, api/service/{{.service}}.mbt — and both
the path and the text are rendered over the parsed spec. mctl template init <dir>
writes a starter that generates and compiles, to edit from there.
config keeps the home between runs, so a team sets it once; a flag on the
command line wins over it. --remote is never taken from the config — checking a
repository out is a decision, not a default.
$ mctl config set home ~/templates
$ mctl config set branch release
$ mctl config get branch
releasegenerate_doc emits an OpenAPI document straight from a .api spec — routes
become paths (a :id segment turns into {id}, with a typed path parameter),
and type blocks become component schemas. It speaks every mainstream dialect,
the same three moonapi's runtime emitter does, so a generated service and a
hand-built app document the same way.
let spec = @moonctl.parse(api_src)
let json = @moonctl.generate_doc(spec, version=OpenApi31) // or Swagger20 / OpenApi30
let html = @moonctl.swagger_ui_stub(spec_url="iframe.php?url=https%3A%2F%2Fgithub.com%2Fopenapi.json")moonctl ships a runtime template engine — the faithful equivalent of goctl's
text/template. Because MoonBit has no reflection, template data is an explicit
tagged Value (Str/Int/Bool/List/Dict/…), the way encoding/json
models dynamic data. It supports {{.Field}} interpolation, {{if}}/{{else if}}/ {{else}}/{{end}}, {{range}} (with $index, $value binding) and {{with}},
$variable assignment, | pipelines with parenthesised sub-pipelines, {{- -}}
whitespace trimming, {{/* comments */}}, and a function library (upper,
lower, title, trim, trimPrefix/trimSuffix, hasPrefix/hasSuffix,
contains, replace, eq/ne/lt/le/gt/ge, and/or/not, len,
index, default, printf/print/println, plus your own via func).
let out = @moonctl.render(
"{{range .routes}}{{.verb}} {{.path}} -> {{.handler}}\n{{end}}",
@moonctl.spec_to_value(spec),
)
// or drive codegen from a caller-supplied template:
let code = @moonctl.generate(spec, template=my_template) // == generate_withShipped: the .api parser (service blocks, @server( … ) route groups,
verb path handler "summary" routes, typed type blocks → request/response
schemas, // comments, and a SpecError naming the line a spec goes wrong on), the
moonapi generator (verified to compile against real moonapi), the runtime
template engine (goctl's text/template equivalent), the cross-repo generators —
.proto → moonrpc service stubs, .api type → moonorm models (struct +
Model + Table + up/down migration), SQL DDL → moonorm models with typed
CRUD, and .api → OpenAPI (Swagger 2.0 / 3.0 / 3.1) + a Swagger UI stub — with
the generated model and CRUD verified to compile against the real moonorm +
moondb and the generated document verified to parse — the plugin system (an
external generator driven off the parsed spec over stdio JSON, with a working
demo plugin), live-datasource schema reflection (model datasource — read a running
SQLite or PostgreSQL schema and generate models, verified end to end against a real
SQLite database), the nested project scaffolds (api/rpc/model new, docker, kube),
and the mctl gen api/proto/model/crud/doc + mctl model datasource + mctl plugin
CLI binary (native), the layered project tree gen api writes (etc/, internal/config,
svc, types, handler, logic, middleware) with goctl's regeneration rule — only the
routes and the types are rewritten, so a second run keeps every handler body you wrote —
goctl's --style naming templates, and multi-file specs (import, single and grouped,
resolved against the importing file). Generated moonapi routing registers each handler through a raising
arrow closure, so a scaffold compiles under moonc 0.10.5 (where a pure named handler no
longer coerces to moonapi's raising ApiHandler). All verified across every backend
(0 warnings under --deny-warn). This round added the spec toolchain (api format,
api validate with per-rule diagnostics, api doc as Markdown), the four typed
clients (api ts, api dart, api kt, api java — each compiled once with its own
compiler, and snapshotted by the tests), migrate (a spec's types as SQL), the
model mysql|pg datasource spellings, and the command surface that makes the rest
discoverable (help, completion, template, config, env, bug, upgrade,
quickstart). The latest round closed the rest of goctl's list: model mongo
(a document model and a typed repository over a collection port, compiled and driven
through a fake driver in the tests), model cache (cache-aside CRUD over a generic
cache port, driven against the generated in-memory cache), gateway (a .proto as
an HTTP front door, with a route mapping and the 200/400/404/502 answers exercised
end to end), and the plugin fault coverage mctl was missing — a plugin may not write
outside the output directory, and a path it claims twice is refused rather than
silently overwritten. Each of the three generators' output is compiled under
moon check --target all --deny-warn as part of that work.
Still open: the template library is catalogued and configurable (--home,
--remote, --branch), but no template directory is read from disk yet — the
bundled catalogue is what generation uses — and mctl template init is advertised
in the usage line without an implementation behind it. The gateway matches a route
by verb and path exactly, so a :param pattern is emitted but never matched.
docs/ is the published documentation — a landing page, a quick start,
three guides, a command reference and the library API. Plain HTML and CSS with one
small script: open docs/index.html in a browser to read it, there is no build step
and nothing to install.
The API page is the exception to "hand written": scripts/gen_api_reference.py
regenerates it from the pkg.generated.mbti interfaces moon info writes, and CI
fails if the committed page and the current interfaces disagree. Run it after
changing a public signature.
Apache-2.0.