6 minutes
Error Codes: API design and Go generators
Error codes in API design
There are many important aspects to take into consideration when desiging APIs, regardless of those being for public of private access. Certain qualities will hopefully make an API a joy to work with. Or a major pain.
Error codes can help with API usage experience for both developers and programs. They’re not at all complicated to implement and they bring a sense of uniformity and clarity to the error responses returned by an API. Besides, with error codes, client applications can much more easily show consistent (and relevant) error messages to end users, as well as to work with translations.
Of course, it doesn’t help if different endpoints return different error structures, even if somewhere in there an error code is present.
My ideas here are quite simple (as the subject is not complex anyways):
Define a single error response structure
and expose it in your API docs:
openapi: 3.0.0
info:
version: 1.0.0
title: Sample API
description: |
## API for Things and Stuff
### Errors (4xx-500 range status codes)
Error responses also always follow their own structure, exposing a message and a code.
The message is simply indicative and most likely more relevant for development than for end users.
Codes are more appropriate for client applications to implement error handling and error message translations.
```
{
"message": "The request route does not exist",
"code": "route_not_found"
}
```
Create human readable error codes
that can still be effortlessly parsed by programs:
method_not_allowed
: The request HTTP method is not allowed in this serverpayload_parse
: Request body payload could not be parsedpayload_size
: Request body payload exceeds the maximum of bytes allowed
Error codes in Go
For a while I’ve been looking for a decent way of implementing this in Go API servers, mostly because Go doesn’t have enums as seen in C# or Java. After trying a few approaches, I ended up liking this one (let’s see how long it lasts).
Here’s a custom struct type to represent the format of all errors returned by the API. You can attach extra utility functions to it for custom JSON encoding, etc.
type AppError struct {
message string
code string
statusCode int
}
func (e AppError) StatusCode() int {}
func (a AppError) MarshalJSON() ([]byte, error) {}
Declare errors:
var ErrorInvalidQueryParam = AppError{
message: "One of the query parameters is not valid",
code: "invalid_query_param",
statusCode: http.StatusBadRequest,
}
Use them:
func main() {
http.HandleFunc("/", hello)
http.ListenAndServe(":4000", nil)
}
func hello(w http.ResponseWriter, _ *http.Request) {
RespondError(w, ErrorInvalidQueryParam, nil)
}
Why does the title mention Go generators?!
Besides a very simple yet complete standard library, Go comes bundled with quite a few tools that make developers' lives easier.
One of those tools is go generate
.
The main goal of generate
is to write Go programs that write Go programs. Meta-programming!
This can be quite useful for a multitude of things such as to generate mocks from interfaces, for example. Many repetitive development tasks can be avoided by using generators to write code for us.
However, generators can be used for anything, really. In this case, I created a generator to parse some code and generate the markdown list of error codes to include in the API spec file.
Generating docs
As much as I would like to have an OpenAPI v3 spec generated from code/comments, at the moment there seems to be no viable option in the Go ecosystem (there is for Swagger v2, though). Therefore, I’ve been keeping the spec file as part of the project, and try to enforce an API-first development approach: API, tests then code.
Even if I used an OpenAPI generator, it would not generate the list of existing error codes anyway. And that’s why I’m here.
In the description section (Markdown), I added 2 comments to signal where the generator should print the codes.
openapi: 3.0.0
info:
version: 1.0.0
title: Sample API
description: |
## API for Things and Stuff
<!-- ERROR_GENERATOR_START -->
<!-- ERROR_GENERATOR_END -->
In the file where the AppError
declarations are kept, we add a special comment that tells go generate
to run a generator on this file:
//go:generate go run errorgen.go
package main
type AppError struct {
message string
code string
statusCode int
}
Now for the implementation of the actual generator. We need to use go/parser, to read the file in question and parse the Go code in it, so we can implement some logic that analyzes the abstract syntax tree (AST) and extracts what we need: the error code values:
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path.Join(cwd, os.Getenv("GOFILE")), nil, parser.ParseComments)
Now the code needs to iterate all relevant top level declarations (which are the var
declarations such as ErrorInvalidQueryParam
). Error codes and respective messages are kept in the errs
map:
errs := map[string]string{}
// iterate top level declarations
for _, d := range file.Decls {
switch decl := d.(type) {
// we're interested in "generic declaration" nodes, which represents variable declaration among other things
case *ast.GenDecl:
// iterate over Specs, which are declarations
for _, spec := range decl.Specs {
analyzeSpec(spec, errs)
}
}
}
analyzeSpec
looks at a node representing a constant or variable declaration and runs extractFields
if it the node is a var of type AppError
:
func analyzeSpec(spec ast.Spec, errs map[string]string) {
switch spec := spec.(type) {
// we're interested in ValueSpec nodes (represent constant or variable declaration)
case *ast.ValueSpec:
// checking if the node is a composite literal of type AppError (AppError{...})
if len(spec.Values) == 1 && spec.Values[0].(*ast.CompositeLit).Type.(*ast.Ident).Name == "AppError" {
code, message := extractFields(spec.Values[0].(*ast.CompositeLit).Elts)
errs[code] = message
}
}
}
extractFields
receives a list of expressions that are part of a composite literal (the literal usage of AppError{}
). In this case these are the struct fields and we want the values of the fields code
and message
:
func extractFields(elts []ast.Expr) (string, string) {
var code string
var message string
// iterate over this composite literal elements (the struct fields: message, code, statusCode)
for _, el := range elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
// extract field name
key := kv.Key.(*ast.Ident).Name
// extract the value for code and message
switch key {
case "code":
code = kv.Value.(*ast.BasicLit).Value
case "message":
message = kv.Value.(*ast.BasicLit).Value
}
}
return code, message
}
The last part is not so interesting. The remaining function (generateOpenapiErrorCodes
) opens the openapi.yaml
file, iterates its lines, identifies the marker comments and with a bit of logic writes the lines over to a new buffer that will replace the file contents in the end.
I encourage you to look at the full code here, specifically this file.
1096 Words
2022-04-13