Skip to content
moonbitstackPublic

About

Project and code generator for MoonBit services.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

90 Commits

Folders and files

Repository files navigation

moonctl (mctl)

A spec-driven code generator for MoonBit — ← goctl.

Documentation · Quick start · Commands · Library API · mooncakes

Check and Test License mooncakes

Moved on mooncakes from Lfan-ke/moonctl to moonbitstack/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.

Serving, and reloading on a save

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.

From spec to code

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 handler

Each 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.

Route groups

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.

More than one service

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.

Struct tags

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 fails

The handler reads the request

The 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.

The project tree

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.mbt

A 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

Naming style

--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.

Multi-file specs

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")

When a spec is wrong

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.

Packages

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

Usage

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 file

As 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 environment

generate works as a synonym for gen, and rpc for proto.

Cross-repo codegen

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_proto reads a proto3 file (service / rpc / message, including stream, repeated, and map<K, V>); generate_grpc emits a struct per message, a @moonrpc.Method descriptor per RPC (its gRPC :path is /package.Service/Method), a <Service>Server handler-registration struct (one synchronous-core handler per RPC, returning its reply or a gRPC @moonrpc.Status), and a <service>_methods() listing.

  • .api type → moonorm: generate_model turns each storable type block into a model struct, a <table>_model : @moonorm.Model[T] (declared columns — an id column is the primary key — plus the from_row decoder and to_columns projection that MoonBit can't synthesise for want of reflection), a <table>_table : @moonorm.Table descriptor, and a <table>_up / <table>_down migration 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 plain Table descriptor. The output compiles against moonorm + 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 + migration

SQL DDL → CRUD

goctl 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).

Live datasource reflection

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/shop

DSN 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).

Annotations instead of hand-built mappings

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.

Project scaffolds

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.yaml

The 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 once

For 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 Once

Plugins

Like 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 write

Checking a spec: api format, api validate

A 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)

Clients: TypeScript, Dart, Kotlin, Java

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.java

mctl 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.

Migrations from a spec

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)

Document models: model mongo

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.

An HTTP front door: gateway

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.

Cache-aside CRUD: model cache

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.

Template library, config, and the housekeeping

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
release

OpenAPI / Swagger

generate_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")

Template engine

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_with

Status & roadmap (transliterating goctl)

Shipped: 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.

Documentation

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.

License

Apache-2.0.

About

Project and code generator for MoonBit services.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages