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:
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.
| Write | Means | Example |
|---|---|---|
string, bool | String, Bool | Name string |
int, int32, rune | Int32 | Age int |
int64 | Int64 | Id int64 |
uint, uint32 | UInt32 | Mask uint32 |
uint64 | UInt64 | Serial uint64 |
byte, bytes | Byte, Bytes | Body bytes |
float32, float64, double | Float, Double | Price float64 |
[]T | Array[T] | Tags []string |
map[K]V | Map[K, V] | Meta map[string]string |
T? or *T | T? | Bio string? |
| any other name | that type | Owner 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:
type Base {
Id int64 `json:"id"`
Kind string `json:"kind"`
}
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:
| Tag | Binds 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 tag | the 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:
| Option | What it says |
|---|---|
optional | the field may be absent; it becomes T? |
omitempty | leave it out of the body when it is empty |
default=v | the value when the client sends none |
options=a|b | one 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:
| Setting | What it does |
|---|---|
group: user | names the group; handlers and logic land in internal/handler/user/ and internal/logic/user/ |
prefix: /api/v1 | put in front of every route's path |
jwt: Auth | recorded on the route; carried into the documentation |
middleware: Log,Trace | recorded on the route, in order |
maxBytes: 1048576 | a body limit, kept in the generated route comment |
timeout: 3s | a per-route timeout, kept the same way |
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:
@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.:idis a path parameter. The request type must declare it, taggedpath:"id".@docbecomes the route summary — the OpenAPI description, the Markdown column, and the comment on the generated handler.@handlernames 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:
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:
| Rule | Finding |
|---|---|
syntax | a line the parser cannot read, with its line number |
import | an imported file that is not there |
duplicate-handler | two routes name the same handler |
duplicate-route | the same verb and path twice |
reserved-handler | a handler name MoonBit will not take as a function |
unknown-type | a request, response, field or embed names a type that does not exist |
path-param | a route and its request type disagree about a path parameter |
empty-service | a service block with no route |
unused-group | a group no route belongs to |
duplicate-type | two type blocks with the same name |
empty-type | a type block with no field |
duplicate-field | two fields with the same wire name |
unused-type | a type no route and no other type refers to |
The count line is what a script checks; the findings are what a person reads:
$ 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)
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.
type (
LoginReq {
Username string `json:"username"`
}
)
@server(group:user,prefix:/api/v1)
service greet{
get /profile/:id (Profile)
}
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.