Spec-driven code generation

Write the contract.
Generate the rest.

moonctl reads a .api spec, a .proto file or a live database schema and writes the MoonBit around it: routes, types, handlers, typed clients in four languages, models and migrations. Change the spec and run it again — the files it owns are refreshed, the ones you wrote are left alone.

four MoonBit targets clients with no dependencies pure logic, no runtime
01 — Output

One spec in. Six files out.

The same parse feeds every generator, so the route your server answers and the method your client calls cannot drift apart.

user-api_client.ts from mctl api ts
// Code generated by `mctl api ts` from a `.api` spec. DO NOT EDIT.

export class UserApiClient {
  readonly baseUrl: string;
  token?: string;

  constructor(baseUrl: string, fetchImpl?: Fetch, token?: string) {
    this.baseUrl = baseUrl.replace(/\/+$/, "");
    this.fetchImpl = fetchImpl ?? ((url, init) => fetch(url, init));
    this.token = token;
  }

  async login(body: LoginReq): Promise<LoginResp> {
    const path = `/api/v1/login`;
    const url = this.baseUrl + path;
    const headers: Record<string, string> = { accept: "application/json" };
    if (this.token !== undefined) {
      headers["authorization"] = "Bearer " + this.token;
    }
    const res = await this.fetchImpl(url, {
      method: "POST",
      headers,
      body: JSON.stringify(loginReqToJson(body)),
    });
    if (!res.ok) {
      throw new ApiError(res.status, await res.text());
    }
    return loginRespFromJson(JSON.parse(await res.text()));
  }
}
user-api_client.dart from mctl api dart
// Code generated by `mctl api dart` from a `.api` spec. DO NOT EDIT.

import 'dart:convert';

class UserApiClient {
  UserApiClient({required String baseUrl, required this.send, this.token})
      : baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), '');

  final String baseUrl;
  final Send send;
  final String? token;

  Future<LoginResp> login(LoginReq body) async {
    final path = '/api/v1/login';
    final url = Uri.parse('$baseUrl$path');
    final headers = <String, String>{'accept': 'application/json'};
    if (token != null) {
      headers['authorization'] = 'Bearer $token';
    }
    final res = await send('POST', url, headers, jsonEncode(body.toJson()));
    if (!res.ok) {
      throw ApiException(res.status, res.body);
    }
    return LoginResp.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
  }
}
user-api_client.kt from mctl api kt
// Code generated by `mctl api kt` from a `.api` spec. DO NOT EDIT.

class UserApiClient(
    private val baseUrl: String,
    private val transport: Transport,
    private val codec: Codec,
    private var token: String? = null,
) {
    fun login(body: LoginReq): LoginResp {
        val path = "/api/v1/login"
        val url = baseUrl + path
        val headers = linkedMapOf("accept" to "application/json")
        token?.let { headers["authorization"] = "Bearer " + it }
        val res = transport.send("POST", url, headers, codec.encode(body))
        if (!res.ok) {
            throw ApiException(res.status, res.body)
        }
        return LoginResp.fromWire(codec.decode(res.body))
    }

    fun getProfile(id: String): Profile {
        val path = "/api/v1/profile/" + enc(id)
        val res = transport.send("GET", baseUrl + path, linkedMapOf("accept" to "application/json"), null)
        if (!res.ok) {
            throw ApiException(res.status, res.body)
        }
        return Profile.fromWire(codec.decode(res.body))
    }
}
UserApiClient.java from mctl api java
// Code generated by `mctl api java` from a `.api` spec. DO NOT EDIT.

public final class UserApiClient {
    public Profile getProfile(String id) {
        String path = "/api/v1/profile/" + enc(id);
        String url = baseUrl + path;
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("accept", "application/json");
        if (token != null) {
            headers.put("authorization", "Bearer " + token);
        }
        RawResponse res = transport.send("GET", url, headers, null);
        if (!res.ok()) {
            throw new ApiException(res.status, res.body);
        }
        return Profile.fromWire(codec.decode(res.body));
    }
}
user-api_api.md from mctl api doc
# user-api

A MoonAPI service with 2 route(s) and 3 type(s).

## Routes

| Method | Path | Handler | Request | Response | Description |
| --- | --- | --- | --- | --- | --- |
| POST | `/api/v1/login` | `Login` | `LoginReq` | `LoginResp` | log a user in |
| GET | `/api/v1/profile/:id` | `GetProfile` | — | `Profile` | — |

### Groups

| Group | Prefix | JWT | Middleware | Max body | Timeout |
| --- | --- | --- | --- | --- | --- |
| `user` | `/api/v1` | — | — | — | — |

### Profile

| Field | Type | In | Required | Constraints |
| --- | --- | --- | --- | --- |
| `id` | `Int64` | path | yes |  |
| `name` | `String` | body | yes |  |
user-api_migrate.sql from mctl migrate
-- One table per `type` block of the spec. A spec says what a value looks
-- like, not what the database should enforce: indexes, uniqueness and foreign
-- keys are yours to add.

CREATE TABLE IF NOT EXISTS `profiles` (
  `id` BIGINT NOT NULL,
  `name` VARCHAR(191) NOT NULL,
  PRIMARY KEY (`id`)
);

CREATE TABLE IF NOT EXISTS `login_reqs` (
  `username` VARCHAR(191) NOT NULL,
  `password` VARCHAR(191) NOT NULL
);
02 — Why

Generated, not scaffolded once.

Write the spec, generate, then go and write the part only you can write.

01

Regeneration is the normal case

Files the spec decides — routes, types, wire formats — are rewritten on every run. Handler bodies, logic and tests are written once and never touched again.

02

One parse, many outputs

Clients, models, docs and migrations come from the same spec tree. Rename a route and every output is renamed in the same run.

03

Nothing to install

The library is pure logic. Generated clients carry a transport port — fetch, a function, a class — instead of a runtime dependency.

04

Loud about bad input

A line the parser cannot read is an error with a line number, never a silently smaller spec. mctl api validate lists what is wrong before any code is written.

03 — Quick start

Three commands to a running shape.

A spec, a check, a project.

1 Write the contract

user.api
syntax = "v1"

type (
  LoginReq {
    Username string `json:"username"`
    Password string `json:"password"`
  }
  LoginResp {
    Token string `json:"token"`
  }
)

@server (
  prefix: /api/v1
  group:  user
)

service user-api {
  @doc "log a user in"
  @handler Login
  post /login (LoginReq) returns (LoginResp)
}

2 Check it

terminal
$ mctl api validate user.api
mctl: user.api is valid

$ mctl api format user.api --write
mctl: formatted user.api

3 Generate

terminal
$ mctl gen api user.api

mctl: wrote moon.mod
mctl: wrote moon.pkg
mctl: wrote user-api.mbt
mctl: wrote etc/user-api.yaml
mctl: wrote internal/config/config.mbt
mctl: wrote internal/svc/servicecontext.mbt
mctl: wrote internal/types/types.mbt
mctl: wrote internal/handler/routes.mbt
mctl: wrote internal/handler/user/loginhandler.mbt
mctl: wrote internal/logic/user/loginlogic.mbt
mctl: wrote 19 file(s) from user.api

What lands in each file is in the quick start.

Start with the spec you already have.

Write a service block, one type, one route. Run the check, then the generator. The project that comes out builds as it is.