Templates
The config changes the generated code in two ways, both with Go templates:
- a block replaces a small piece of a built-in template, such as the body of every method of the service scaffold
- an extra file is a file of your own, written from your template on every run
Nothing replaces a whole built-in template, so you never copy one to change a few lines. To write a file your own way, leave its scaffold out and write it as an extra file.
Examples
From simple to complex. Each one starts from a need and shows the config for it. The health route, the 501 stubs and the mock server are examples in this repository, built and tested on every change.
A health route
A load balancer asks the server for /health, which the spec does not have. Add the route in
server.router-extra:
templates:
server.router-extra: |
r.Get("/health", health)
Write the handler in a file of your own, in the package of the router:
var health = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
The route goes next to the generated ones, so the middleware wraps it too. This line is for chi;
Routes has it for every router. See examples/templates/blocks.
Stubs that answer 501
Every method of the service scaffold returns ErrNotImplemented, and the server answers that with
500. To answer 501 Not Implemented until a method is written:
imports:
- package: net/http
templates:
server.scaffold.service-method: |
return &{{.Data}}{Status: http.StatusNotImplemented}, nil
Each method of the scaffold is then:
func (s *Pets) ListPets(ctx context.Context, opts *ListPetsServiceRequestOptions) (*ListPetsResponseData, error) {
return &ListPetsResponseData{Status: http.StatusNotImplemented}, nil
}
The generator cannot know which packages your text names, so net/http is listed under
imports. See examples/templates/blocks.
A mock of the service
To test the HTTP layer against a mock of the service, let mockgen
write one. A go:generate line before the interface does it:
templates:
server.service-header: |
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -destination=mock_test.go -package=$GOPACKAGE . {{.Name}}
{{.Name}} is the name of the interface, PetsInterface here. go generate ./... then writes
MockPetsInterface into mock_test.go. The tests need go.uber.org/mock in the module. The same
line under client.interface-header mocks the interface of the client.
A mock server
examples/templates/wrapper answers every operation with an empty success response, so a client
can be built before the service is written. It takes three pieces:
server:
framework: chi
name: Pets
scaffold: {service: ./service.go}
templates:
server.request-options-extra: |
// GenerateResponse makes the response, when the service is asked for one.
GenerateResponse func() (*{{.Data}}, error)
server.scaffold.service-method: |
return opts.GenerateResponse()
extra-files:
./wrap.go: {file: ./wrapper.tmpl}
server.request-options-extragives the request options of every operation a typed field.server.scaffold.service-methodmakes every method of the scaffold return what that field makes.- The handlers leave the field unset. The extra file
wrap.gowritesWithBodies, a service that sets the field and then calls the service it wraps.
wrapper.tmpl ranges over the operations of the data. For the success response of
each operation, it calls the constructor:
- with
newfor a pointer body - with a zero value for another body
- without a body when there is none
An operation without a success response answers 200:
{{- $context := import "context"}}
// withBodies sets GenerateResponse on the options of every operation, then calls the service.
type withBodies struct {
svc {{expr .Service}}
}
// WithBodies returns svc with GenerateResponse set to answer with an empty success body.
func WithBodies(svc {{expr .Service}}) {{expr .Service}} {
return withBodies{svc: svc}
}
{{- range $op := .Operations}}
func (s withBodies) {{.ID}}(ctx {{$context}}.Context, opts *{{expr .RequestOptions}}) (*{{expr .ResponseData}}, error) {
opts.GenerateResponse = func() (*{{expr .ResponseData}}, error) {
{{- with .Success}}
{{- if .Body.Elem.Name}}
return {{expr .Constructor}}({{if .HasStatusArg}}{{.Code}}, {{end}}new({{expr .Body.Elem}})), nil
{{- else if .Body.Name}}
var body {{expr .Body}}
return {{expr .Constructor}}({{if .HasStatusArg}}{{.Code}}, {{end}}body), nil
{{- else}}
return {{expr .Constructor}}({{if .HasStatusArg}}{{.Code}}{{end}}), nil
{{- end}}
{{- else}}
return &{{expr $op.ResponseData}}{Status: 200}, nil
{{- end}}
}
return s.svc.{{.ID}}(ctx, opts)
}
{{- end}}
The server is NewRouter(WithBodies(NewPets())).
Blocks
A block is a named piece of a built-in template. Until the config replaces it with text of its own, the block writes its default.
The value of a block is the template text. To keep the text in a file, write {file: <path>}
instead, with the path relative to the config. The form of the value tells the two apart. A text is
never read as a path, whatever it looks like.
templates:
server.service-header: {file: ./templates/header.tmpl}
server.router-extra: |
r.Get("/health", {{.User.health}})
user-context:
health: healthHandler
| Block | Where | Data | Default |
|---|---|---|---|
client.interface-header |
before the client interface | the interface | nothing |
server.service-header |
before the service interface | the service | nothing |
server.request-options-extra |
in every request options struct, before RawRequest |
the operation | nothing |
server.response-data-extra |
in every response data struct, after Body |
the operation | nothing |
server.router-extra |
in the router's registration, after the routes | the router | nothing |
server.scaffold.service-fields |
in the struct of the service scaffold | the scaffold | nothing |
server.scaffold.service-method |
the body of every method of the service scaffold | the method | return nil, ErrNotImplemented |
Line breaks
The text of a block goes on lines of its own, without the blank lines around it. So it needs no line break at its start or its end.
server.service-header and client.interface-header are followed by a blank line. That keeps them
out of the comment of the interface.
A block whose text comes out empty adds nothing.
Names in a block
| Block | Field | Holds |
|---|---|---|
server.service-header |
.Name |
the name of the service interface |
client.interface-header |
.Name |
the name of the client interface |
server.request-options-extra, server.response-data-extra |
.Options, .Data |
the names of the two structs of the operation |
server.scaffold.service-fields |
.Name |
the name of the service struct |
server.scaffold.service-method |
.Name |
the name of the method |
server.scaffold.service-method |
.Options, .Data |
the method's two structs, written as the scaffold's file spells them |
Funcs in a block
The text of a block is a text/template of its own. It can call the funcs every template of the
generator has, such as comment and quote, listed under Funcs.
It has no expr, import or symbol. The packages its code names are listed under
imports.
User context
user-context is available as .User in every block and as .UserContext in an extra file.
A key it does not have is an error: {{.User.team}} fails in a config that sets no team. To read
a key that may be missing, use index under if or with, as in {{if index .User "team"}}.
A block that writes <no value> anyway is an error too. It prints a key the config gives no value,
or an index on its own.
Block errors
These are config errors:
- a block that does not exist, with the list of those that do
- a
serverblock in a config withoutserver, and aclientblock in one withoutclient - a scaffold block in a config that does not write that scaffold
- a value without text, and
{file: }without a path - text that can only be a path, such as
./header.tmplortemplates/header.txt
Text that can only be a path would be written into the code as it is. So the error asks for
{file: ./header.tmpl}.
Routes
In server.router-extra, the router and the way you add a route to it depend on the framework. Each
line below adds GET /health, with health an http.HandlerFunc of yours, as in
A health route.
| Framework | Router | The route |
|---|---|---|
| chi | r, a chi.Router |
r.Get("/health", health) |
| std-http | mux, an *http.ServeMux |
mux.Handle("GET /health", route(health)) |
| echo, echo-v5 | e, an *echo.Echo |
e.GET("/health", echo.WrapHandler(health), m...) |
| kratos | r, a *khttp.Router |
r.GET("/health", func(c khttp.Context) error { health(c.Response(), c.Request()); return nil }) |
| gin | e, a *gin.Engine |
e.GET("/health", handle(route(health))) |
| fiber | app, a *fiber.App |
app.Get("/health", handle(route(health))) |
| iris | app, an *iris.Application |
app.Get("/health", handle(route(health))) |
| gorilla-mux | r, a *mux.Router |
r.Handle("/health", route(health)).Methods("GET") |
| fasthttp | r, a *router.Router |
r.GET("/health", handle(route(health))) |
| beego | r, a *web.ControllerRegister |
r.AddMethod("GET", "/health", handle(route(health))) |
| go-zero | r, an httpx.Router |
handle(r, "GET", "/health", route(health)) |
| hertz | h, a *server.Hertz |
h.GET("/health", handle(route(health))) |
| goframe | s, a *ghttp.Server |
s.BindHandler("GET:/health", handle(route(health))) |
- Pass a route through
route, orm...on echo, and the middleware ofWithMiddlewarewraps it like the generated ones. On chi and kratos the routerradds the middleware itself. handlemakes the framework's handler from anhttp.Handler.- On gin,
handlealso takes the names of the path values after the handler, in path order:handle(route(h), "id"). A value without a name keeps the name in gin's pattern.
Imports
The text of a block is Go code the generator did not write, so it cannot know which packages that
code needs. List them under imports:
imports:
- package: github.com/google/uuid
- {package: example.com/shop/tenant, alias: tn}
templates:
server.request-options-extra: |
TraceID uuid.UUID
Tenant tn.ID
The file that holds the request options now imports github.com/google/uuid, and
example.com/shop/tenant as tn. A generated file imports a listed package when its code names
it, and leaves it out otherwise. So one list serves every file.
A package that an x-go-type names is listed the same way.
Package names
The name of a listed package is its alias. Without an alias, the name is the last element of the
path, without:
- a major version:
chiforgithub.com/go-chi/chi/v5 - a
go-prefix - what follows a dot:
yamlforgopkg.in/yaml.v3
A package named any other way needs the alias.
Name clashes
In a file that names a listed package, the name is that package's. Another package the generator
imports under the same name gets a number in that file, such as models2.
A listed package that the generator imports too goes by the listed name in the generated code. So
an alias for such a package must not be a name that code gives a variable, like ctx or r.
Side effects and unused entries
- With
alias: _every generated file imports the package, for its side effects. - An entry that no file names changes nothing. It is reported as an
import-unusedwarning.
Import errors
These are config errors:
- an entry without
package - an alias that is not a Go identifier
alias: .: Go rejects a file that imports a package without using it, and with.the use cannot be checked- a path that is listed twice under a name, or twice under
_ - two entries with one name
- a path that gives no name, such as
example.com/9lives, without an alias
Extra files
extra-files maps a path to a template: its text, or {file: <path>} with the path relative to
the config, as for a block.
extra-files:
./wrap.go: {file: ./wrapper.tmpl}
./routes.go: |
// Routes lists the method and path of every route the router registers.
var Routes = []string{
{{- range .Operations}}{{if .IsRouted}}
{{quote (print .Method " " .Path)}},
{{- end}}{{end}}
}
An extra file is a generated file like the others. It gets the header, the package of its folder and the imports its code names, and it is written on every run.
Its template runs on the data: the service, every operation with its types, responses
and constructors, and every model type. The template writes Go declarations, and the generator
formats them. For the pets API, routes.go is:
// Routes lists the method and path of every route the router registers.
var Routes = []string{
"GET /pets",
"POST /pets",
"DELETE /pets/{id}",
"GET /ping",
}
A mock server shows a larger one, wrapper.tmpl.
Funcs
The template of an extra file sees two sets of funcs. Three are bound to the file it writes: expr,
import and symbol. The others are the funcs every template of the generator has.
| Func | Does |
|---|---|
expr <TypeRef> |
writes the type as the file spells it, qualified and imported when it lives in another package |
import <path> |
imports the path and returns the name to qualify with |
symbol <part> <name> |
writes a name the part declares as the file spells it, qualified and imported when the part is in another package |
comment <text> |
writes the text as // lines, wrapped to 100 columns where a space allows, and nothing for blank text |
quote <text> |
writes the text as a Go string literal |
lower <text> |
lowers every letter |
ucFirst <text> |
raises the first character |
toGoComment is comment under another name. escapeGoString is quote without the quotes around
the text. The generator's tag is of no use here: it takes the tags of a model field, and an extra
file has no such value.
symbol
symbol reaches what the data does not list: the functions of the server, such as NewRouter and
WithErrorHandler, and what another extra file declares.
The part is named as in output.files, such as server.router. A scaffold is named
server.scaffold.service, and an extra file by its path as the config writes it, such as
./wrap.go. For a chi router:
{{- $http := import "net/http"}}
// Handler returns the router of svc.
func Handler(svc {{expr .Service}}) {{$http}}.Handler {
return {{symbol "server.router" "NewRouter"}}(svc)
}
In the folder of the router this writes NewRouter. In another folder it writes api.NewRouter,
for a router in package api, and imports that package. So an extra file can sit in a package of
its own.
These are errors:
- a part the config does not write, such as
server.routerwithout aserverblock - a name that is not an identifier
- a name that is not exported, when the part is in another folder
The generator does not check that the part declares the name. The Go compiler reports that.
Import cycles
An extra file can use the types and functions of any package the generator writes. Its path decides
what it imports. When it makes two output folders import each other, Generate fails with the
cycle and the file that closes it, as it does for the built-in parts:
import cycle: api -> models -> api (server.service uses example.com/work/models, ./wrap.go uses example.com/work/api)
Move the extra file to a folder that the folders it uses do not import.
Missing keys
text/template alone writes <no value> into the code for a key that a map does not have. In an
extra file that is an error: {{.UserContext.owner}} fails in a config that sets no owner.
To read a key that may be missing, use index under with or if, as in
{{with index .UserContext "owner"}}// Owned by {{.}}.{{end}}.
Extra file errors
A template that does not parse or run, or that writes no Go declarations, is an ErrExtraFile with
the path.
These are config errors:
- a path that is not a
.gofile - a path that
output.file,output.filesor a scaffold writes too, or two paths that are one file - a value as for a block: without text, with text and a file, or text that can only be a path
The data
The template of an extra file runs on an API: the generated code once names are resolved and
files are laid out. The struct only ever gains fields.
type API struct {
Package string // package of output.file, as package or output.packages names it
Service TypeRef // the service interface, empty without a server block
Operations []Operation // every operation and webhook, in spec order
Types []TypeRef // every declared model type
UserContext map[string]any // the config's user-context
}
type Operation struct {
ID string // the Go name, which handlers report as the operation ID
Method, Path string
Summary string
Tags []string
HasOptions bool // takes parameters or a body
IsRouted bool // the router registers it: not a webhook, not dropped
RequestOptions TypeRef // <Op>ServiceRequestOptions, empty without a server block
ResponseData TypeRef // <Op>ResponseData, empty without a server block
ClientRequestOptions TypeRef // <Op>RequestOptions, empty without a client block or for a webhook
ClientResponse TypeRef // <Op>Response, empty unless client.with-response is set
Bodies []Body // every request body, in spec order
Responses []Response // every response, in the order of the generated code
Success *Response // the first of them with a Code from 200 to 299, or nil
}
type Body struct {
ContentType string // application/json
Field string // the field of the request options that holds it: Body, or BodyJSON next to others
Type TypeRef // the type of that field, such as *Pet
}
type Response struct {
Status string // the key as the spec writes it: 200, 2XX, default
Code int // the code read from the key: 200 for 2XX, 0 for default
ContentType string // of the JSON body, else the first one read whole, else the first stream
Body TypeRef // the type the constructor takes, one frame with IsStream; empty without a body
IsRaw bool // the body has no schema: any, string or []byte
IsStream bool // the body is sequential: the constructor takes an iter.Seq of Body
Constructor TypeRef // makes the response data of this status; empty without a server block
HasStatusArg bool // the constructor takes the status first
}
Responses
Responses come in the order of the generated code: codes, ranges, other keys, then default. The
order in the spec does not matter.
Code is what the generated client and error mapping read from the key:
| Key | Code |
|---|---|
| a number | the number |
a range such as 2XX |
the first digit times 100 |
anything else (default) |
0 |
A key that is not a code from 100 to 599, not a range and not default is left out. Generation
warns (invalid-status).
Success is the first response with a Code from 200 to 299. It points into Responses.
Bodies
Bodies lists the request bodies the server and the client options hold, each with the field it
is in, so a template can make one. For a JSON body of type *Pet:
{{- range .Bodies}}{{if eq .ContentType "application/json"}}
body := new({{expr .Type.Elem}})
{{- end}}{{end}}
Constructors
Constructor is the function a hand-written service calls to answer with one status, such as
NewGetPetResponseData404. It takes these arguments, in order:
- The status, when
HasStatusArgis set. That is when the key is not a number, such as a range ordefault. - The body, when the response has one. With
IsStreamset, the body is aniter.SeqofBody: the frames of a response that is only a stream.
A response with a body read whole and a stream describes the first. Its stream has a constructor of
the same name with the suffix Stream.
A constructor also sets the content type the spec gives. That field is unexported, so a literal of
ResponseData in another package cannot set it.
Write the constructor with expr, like a type. For a success response with a body, and body of
the type it takes:
{{- with .Success}}
return {{expr .Constructor}}({{if .HasStatusArg}}{{.Code}}, {{end}}body), nil
{{- end}}
examples/templates/wrapper/wrapper.tmpl calls it for every operation: with a body, with new for
a pointer body, and without one.
TypeRef
TypeRef is a Go type, or a function such as Constructor. It has three parts:
| Part | Holds |
|---|---|
Name |
the type as the package that declares it writes it |
Package |
the name of the package of the identifier in it |
ImportPath |
the import path of the identifier in it |
With an import path
Name is an identifier, or a pointer, slice, array, map or channel around one (Pet, []Pet,
*Pet, map[string]Pet). The package goes before that identifier. A map key or an array length is
written as it is.
A generic type, a func type or a name that is qualified already cannot carry an import path.
Package, when set, is the name of the package: an identifier other than _, and only with an
import path.
Without an import path
The type needs no import. Name is written as it is (string, func() any) and Package is
empty.
A type the generator declares has neither a package nor an import path when the output is one package outside a module.
Writing a type with expr
expr writes a type as the file being written spells it and adds the import.
It fails on a type whose Name cannot carry its import path, and on a Package without an import
path. It fails in every file, so a template does not start to fail when the output is split into
packages.
To write a type around one of another package, put it together in the template:
Page[{{expr .Body}}].
Elem
Elem() is the type a pointer points to, in the same package: Pet for *Pet. It is empty for
any other type.
A constructor that takes *Pet gets a body that is not nil from new({{expr .Body.Elem}}), or
from var body {{expr .Body.Elem}} passed as &body.