OpenAPI
craftgo emits OpenAPI 3.1 from the same DSL that drives the handlers. The spec is a first-class artifact, not an afterthought.
At a glance
Every craftgo gen produces docs/openapi.yaml with:
- Every method as a
pathsentry, but a@hiddenone - Every non-generic type, enum, scalar and error as a
components.schemasentry, but one only hidden methods use and a request type that is all parameters - Every validator decorator mapped to its OpenAPI keyword (
minLength,pattern,enum, ...) - Doc comments flowing into descriptions, a service's into its tag's
- Security schemes from your config
The spec renders directly in Swagger UI and ReDoc, and feeds openapi-generator for client libraries in any language - the day-to-day tools accept it as-is.
Nullability uses the canonical 3.1 idiom - type: [T, "null"] for inline types and anyOf: [{$ref}, {type: "null"}] for named/generic refs - not the removed 3.0 nullable: true boolean, so strict 3.1 validators (Spectral, Redocly) and client generators (hey-api, openapi-typescript) keep the | null union instead of silently dropping it.
The rest of this page walks through what's emitted and how to render or publish it.
What gets generated
Every craftgo gen writes docs/openapi.yaml covering:
paths- one entry per route, an operation perservicemethod but a@hiddenone, with its path, query, header and cookie parameters, request body and responses written in placecomponents.schemas- every non-generictype,enum,scalaranderrorbut one only hidden methods use, with full structure, each generic instance a schema or an operation refers to (PageOfUser), and the<Method>ReqBody/<Method>RespBodyof each JSON body. A request type no operation takes a body from - its fields all bound to the path, the query, headers or cookies - is left out, with a mixin of it that binds no body field, unless a schema refers to itcomponents.securitySchemes- whenopenapi.securitySchemesis in your configtags- each tag an operation carries, in the order the operations first carry themservers- theopenapi.basePath, as the routes take it:api,/api/and//apiare all/api
Validity
The output is consumed cleanly by:
- The official OpenAPI parser behind Swagger UI and ReDoc - renders without errors.
openapi-generatorand similar client generators.- oasdiff - breaking-change detection between versions.
YAML 1.1 readers such as PyYAML, which the Python validators load documents with, read the document as YAML 1.2 readers do: a string YAML 1.1 takes for another type - =, <<, yes, 2001-12-14 21:59:43 -5, 0b_ - is double-quoted, a float always has a dot (1.0e-07), and DEL, a C1 control, U+FFFE or U+FFFF in a doc or a value is written as an escape.
The structure (paths, schemas, parameters, anyOf and not for cross-field constraints, anyOf for responses sharing a status, propertyNames for map keys) is valid 3.1: Redocly CLI's structural rules and openapi-spec-validator accept it, given security schemes that set no field of another type, such as a bearerFormat on a basic scheme; craftgo gen stops on a scheme an @security names that misses a field its type requires. Their style rules may still warn, about an operation without a summary, say.
Renders
The spec renders in any OpenAPI viewer:
- Swagger UI - drop in
swagger-ui-distand point atopenapi.yaml - ReDoc -
<redoc spec-url='openapi.yaml'></redoc> - Stoplight Elements -
<elements-api apiDescriptionUrl="openapi.yaml" />
Client generation
Use the spec to generate clients in any language. Some popular options:
# TypeScript (typed fetch wrappers)
npx openapi-typescript-codegen -i docs/openapi.yaml -o client/
# Java
openapi-generator-cli generate -i docs/openapi.yaml -g java -o client-java/
# Python
openapi-generator-cli generate -i docs/openapi.yaml -g python -o client-python/
# Rust
openapi-generator-cli generate -i docs/openapi.yaml -g rust -o client-rust/The generated client matches the contract because both come from the same DSL.
Operation IDs
Each method gets an operationId derived from its DSL name:
service UserService {
get GetUser /users/{id} { ... }
}becomes
paths:
/v1/users/{id}:
get:
operationId: GetUserThe operationId is the bare method name (GetUser) when that name is unique across the project. If two services declare a method with the same name, both are prefixed with the service name (OrdersServicePing / CatalogServicePing) so every operationId stays globally unique.
Override with @operationId:
@operationId("getUserById")
get GetUser /users/{id} { ... }Two methods that resolve to the same operationId (two explicit @operationId("...") sharing a value, or an override that collides with another method's auto id) are reported at design time, so the spec never carries a duplicate. A @hidden method, which the document leaves out, may share one; its name still counts toward the service prefix, so hiding a method changes no other method's operationId.
Schema components
Each type becomes a reusable schema:
type User { id string name string email string }components:
schemas:
User:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string }
required: [id, name, email]Field-level validators map to OpenAPI keywords:
| Decorator / shape | OpenAPI |
|---|---|
Non-optional field (no ?) | listed in required: [...] |
name string? | omitted from required: [...], type: [T, "null"] |
@nullable | type: [T, "null"] (or anyOf: [{$ref}, {type: "null"}]) |
@default(v) | default: v |
@length(1, 80) | minLength: 1, maxLength: 80 |
@minLength(1), @maxLength(80) | same as above |
@pattern("...") | pattern: ... |
@format(email) | format: email |
@gte(0), @lte(100) | minimum: 0, maximum: 100 |
@gt(0), @lt(100) | exclusiveMinimum: 0 / exclusiveMaximum: 100 |
@minItems(1), @maxItems(10) | minItems: 1, maxItems: 10 |
@uniqueItems | uniqueItems: true |
@example("alice") | example: alice; @example(null) is example: null |
@deprecated | deprecated: true |
int8, int16, int32, an unsigned type | minimum and maximum of the Go type's range, which a bound on the field replaces on its side |
float32 | exclusiveMinimum / exclusiveMaximum at ±3.4028235677973366e+38, the least magnitude that overflows a float32 |
bytes | type: string, contentEncoding: base64 |
@length / @minLength / @maxLength on bytes | the description says the decoded length (Decodes to at most 64 bytes.) |
map<int, V>, map<uint8, V> | propertyNames: {type: string, pattern: ^[+-]?[0-9]+$}, unsigned without the sign: ^[0-9]+$ |
minLength and maxLength on a bytes value would count the characters of its base64 text, not the bytes the validator counts, and no schema keyword counts the bytes a base64 text decodes to, so the description carries the length instead.
A parameter carries its field's description, deprecation and example; its schema holds the type, the constraints and the default.
Only a JSON body field admits null. A parameter, a response header and a multipart part are sent or not: ? makes one optional, and its schema is the type alone.
On a float field the validator compares against the literal's float, and a bound judges the literal and that float as the validator does: @lte(0.1) on a float32 field is maximum: 0.10000000149011612, which both pass, and @gt(0.1) is exclusiveMinimum: 0.10000000149011612, which both fail.
Documentation flows through
DSL doc comments become OpenAPI descriptions; @summary("...") sets an operation's summary:
// Create a new user. The server fills the id and timestamps;
// the client supplies name and email.
@summary("Create a user")
post CreateUser /users {
request CreateUserReq
response User
}paths:
/v1/users:
post:
description: |-
Create a new user. The server fills the id and timestamps;
the client supplies name and email.
summary: Create a userPer-field docs flow into the schema's property description.
Errors
Each error becomes a component named after its Go type (UserNotFound → UserNotFoundErr). Each error a method's @errors(...) lists adds a response at its category's status, described by the category:
type CreateUserReq {
name string
email string
}
type User {
id string
name string
email string
}
error NotFound UserNotFound
error Conflict EmailTaken { email string }
service UserService {
@errors(UserNotFound, EmailTaken)
post CreateUser /users {
request CreateUserReq
response User
}
}The operation's responses and the two error components:
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRespBody'
description: Created
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/UserNotFoundErr'
description: NotFound
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/EmailTakenErr'
description: ConflictEmailTakenErr:
description: Conflict error response (HTTP 409).
properties:
email:
type: string
required:
- email
type: object
UserNotFoundErr:
description: NotFound error response (HTTP 404).
properties:
code:
type: string
message:
type: string
required:
- code
- message
type: objectAn error with no field, like UserNotFound, is documented as the code and message the server sends for it. A component name two packages' declarations share is prefixed with each one's package: error a.Missing and type b.MissingErr are AMissingErr and BMissingErr, as two User types are AUser and BUser. Errors of one category share its response, their schemas in an anyOf, as does a success @status with that code: two errors may send bodies both schemas admit, such as two {code, message} envelopes. A header they send under one name admits each one's type.
The errors the framework writes itself - 400 for a request that fails decoding, binding or validation, 404, 405, 413 for a body over its cap, 500 for a panic or an untyped error, 504 for a deadline - are JSON {"message": "..."} at their status and are not in the document, which lists each operation's success response and its @errors only.
Response headers
A response's @header fields are its headers, and its @cookie fields share one Set-Cookie header, described by the cookies it sets; a @header("Set-Cookie") field joins it, its description first. A header is required when every body its status may answer sends it whatever the body holds: a field neither optional nor an array, as an empty array sends none. A HEAD operation's responses carry their headers and no content.
Tags
An operation's tags are its service's @tags, its @group, then its own @tags, else the service's name. The top-level tags list holds each of them in the order the operations first carry them, which Swagger UI and ReDoc order their groups by. A service's doc comment or @doc describes its tag: its first @tags, else its @group, else its name; of services sharing one, the first by package and name describes it.
An enum whose values have a doc or @deprecated describes each value, in order, in x-enum-descriptions, which openapi-generator puts beside the constants it generates.
Security schemes
Define schemes in craftgo.design.yaml:
openapi:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWTReference them per-method in DSL:
@security(bearerAuth)
get GetUser /users/{id} { ... }For a public method inside an otherwise-authenticated service, use @ignoreSecurity at the method level to drop the inherited chain:
@security(bearerAuth)
service Users {
get GetUser /users/{id} { ... } // requires bearerAuth
@ignoreSecurity
get Healthz /healthz { ... } // no security clause emitted
}The spec carries the security requirement; runtime enforcement is your middleware's job.
Hidden endpoints
@hidden on a method, a service or an extend service block leaves those methods out of the document, while their routes are still generated and served:
@prefix("/hidden")
service HiddenNotes {
get Read /note { response Note } // documented
@hidden
@security(InternalKey)
get Peek /peek { request Stash response Vault<Secret> }
}What only hidden methods use leaves with them: Stash, the enum it binds, Secret, VaultOfSecret and the InternalKey security scheme are not in components, so Swagger UI lists neither the endpoint, its types nor its scheme. Note, which Read answers, stays, and so does a declared type no method uses.
Spec location
By default docs/openapi.yaml. Change with output.openapi in craftgo.design.yaml:
output:
openapi: ./api/openapi.yamloutput.openapi: "-" writes no document. What only the document gets wrong, such as two component schemas sharing a name or an oauth2 security scheme without flows, stops a run that writes it, and no other: with "-", or with craftgo gen --target go, the Go code is generated. A new main.go embeds and serves the document only when it is on disk as the Go code is generated, so a project first generated with --target go gets a main.go without it; add the embed once the document exists. Until then, each craftgo gen names that main.go and the embed it lacks.