The .api spec

A spec file has three kinds of statement: a block of settings, type blocks, and a service block of routes. This page is the whole language.

The shape of a file #

One file, read top to bottom:

shop.api
syntax = "v1"

info (
  title:   "shop"
  version: "v1.2"
)

type (
  LoginReq {
    Username string `json:"username"`
    Password string `json:"password"`
  }
  Profile {
    Id   int64  `path:"id"`
    Name string `json:"name"`
    Bio  string `json:"bio,optional"`
    Tier string `json:"tier,options=free|pro"`
    Age  int    `json:"age,range=[18:120]"`
  }
)

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

service shop {
  @doc "log a user in"
  @handler Login
  post /login (LoginReq) returns (Profile)

  @handler GetProfile
  get /profile/:id returns (Profile)
}

Block order between types and the service does not matter — a type may be declared after the route that returns it. Inside a service it does: @server applies to the service below it, and @doc and @handler to the route below them.

Types #

A type block is a name and a field per line, Name type `tag`. A type ( … ) group declares several at once.

WriteMeansExample
string, boolString, BoolName string
int, int32, runeInt32Age int
int64Int64Id int64
uint, uint32UInt32Mask uint32
uint64UInt64Serial uint64
byte, bytesByte, BytesBody bytes
float32, float64, doubleFloat, DoublePrice float64
[]TArray[T]Tags []string
map[K]VMap[K, V]Meta map[string]string
T? or *TT?Bio string?
any other namethat typeOwner User

A line that is only a type name embeds that type, fields first. This is how a spec shares the columns every row has:

base.api
type Base {
  Id   int64  `json:"id"`
  Kind string `json:"kind"`
}
user.api
type User {
  Base
  Name string `json:"name"`
}

User documents and validates as id, kind, name — the embedded fields come first, and a nested embed is flattened the same way.

Tags #

The back-tick tag says where a field's value comes from:

TagBinds from
json:"name"the request body, under the wire name name
form:"page"the query string
path:"id"a :id segment of the route
header:"X-Client-Id"a request header
no tagthe request body, under the field's own name

Options follow the wire name, comma-separated, and become validation in the generated service and a constraints column in the reference:

OptionWhat it says
optionalthe field may be absent; it becomes T?
omitemptyleave it out of the body when it is empty
default=vthe value when the client sends none
options=a|bone of a fixed set — rendered as one of `a`, `b`
range=[18:120]a numeric bound; [ and ] include, ( and ) exclude

A field may carry one binding and any number of options — `json:"bio,optional"`. The tag is kept as written, so anything the tool does not read is still in the file after a format.

Groups #

@server ( … ) applies to the service under it. Every route in that block belongs to the group:

SettingWhat it does
group: usernames the group; handlers and logic land in internal/handler/user/ and internal/logic/user/
prefix: /api/v1put in front of every route's path
jwt: Authrecorded on the route; carried into the documentation
middleware: Log,Tracerecorded on the route, in order
maxBytes: 1048576a body limit, kept in the generated route comment
timeout: 3sa per-route timeout, kept the same way
Recorded, not wired

jwt, middleware, maxBytes and timeout have no call in the generated server to hand them to. They are written into the route table as a comment rather than dropped in silence, so the generated file still says what the spec asked for.

Routes #

A route is a verb, a path, a request type and a response type:

route forms
@doc "log a user in"
@handler Login
post /login (LoginReq) returns (LoginResp)

@handler GetProfile
get /profile/:id returns (Profile)

@handler Ping
get /ping
  • (LoginReq) is the request type; without one the route takes no body.
  • returns (LoginResp) is the response type; without one the route answers with what the handler writes.
  • :id is a path parameter. The request type must declare it, tagged path:"id".
  • @doc becomes the route summary — the OpenAPI description, the Markdown column, and the comment on the generated handler.
  • @handler names the two generated functions; two routes may not share one.

A route may name its handler inline instead — get /ping ping "health check" — which is the older one-line spelling. Both parse; format keeps whichever the file used.

More than one file #

A spec may pull in another, or a group of them:

user.api
import "base.api"

import (
  "shared/types.api"
  "shared/errors.api"
)

Paths resolve relative to the file that wrote them. The merged spec is what every command works on: api validate checks the composed spec, not the entry file alone, so a type imported from base.api is in scope for a route in user.api.

Checking #

mctl api validate prints every finding as severity [rule] at: message, then a count. It checks the routes, the types and the references between them:

RuleFinding
syntaxa line the parser cannot read, with its line number
importan imported file that is not there
duplicate-handlertwo routes name the same handler
duplicate-routethe same verb and path twice
reserved-handlera handler name MoonBit will not take as a function
unknown-typea request, response, field or embed names a type that does not exist
path-parama route and its request type disagree about a path parameter
empty-servicea service block with no route
unused-groupa group no route belongs to
duplicate-typetwo type blocks with the same name
empty-typea type block with no field
duplicate-fieldtwo fields with the same wire name
unused-typea type no route and no other type refers to

The count line is what a script checks; the findings are what a person reads:

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

$ mctl api validate broken.api
error [syntax] line 16: route get /orders/:id has no handler; name one with `@handler`
mctl: broken.api: 1 error(s), 0 warning(s)
Nothing is skipped

A line the parser cannot read stops the parse and is reported. It is never dropped, because a spec that quietly loses a route generates a service that quietly lacks it.

Formatting #

mctl api format writes the one layout the tool calls canonical: two spaces per nesting level, a space after the colon in a group setting, one blank line between blocks, type ( … ) and @server ( … ) indented like any other block, and one newline at the end.

before
type (
LoginReq {
Username string `json:"username"`
}
)
@server(group:user,prefix:/api/v1)
service   greet{
get /profile/:id   (Profile)
}
after
type (
  LoginReq {
    Username string `json:"username"`
  }
)

@server (group: user, prefix: /api/v1)
service greet {
  get /profile/:id (Profile)
}

Comments, struct tags and the order of statements survive; only the layout changes. The result is idempotent — formatting a formatted file returns it unchanged — so a format --write in a hook produces a diff exactly when someone wrote something that was not canonical.