Documentation
¶
Overview ¶
Package goenax is a code-as-source-of-truth registry for HTTP endpoints.
An endpoint is declared once — typed request/response plus metadata — and the registry projects that declaration into OpenAPI (and, downstream, Postman and TypeScript). Because the request/response schemas are taken from real Go types, the contract cannot lie about the shapes the handler binds and returns.
The core package uses only the standard library; framework integration lives in the adapter packages (adapter/echo, adapter/gin, adapter/nethttp).
Example ¶
Declare an endpoint once; the registry turns it into an OpenAPI document. In an application the adapters (adapter/echo, adapter/gin, adapter/nethttp) call Define and Add for you while they mount the route.
package main
import (
"encoding/json"
"fmt"
"net/http"
"slices"
goenax "github.com/Lev2098/Goenax"
)
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
type Session struct {
Token string `json:"token"`
Role string `json:"role" validate:"oneof=admin member"`
}
func main() {
reg := goenax.New()
reg.Add(goenax.Define(http.MethodPost, "/api/login",
goenax.Summary("Exchange credentials for a session"),
goenax.Description("Returns a bearer token."),
goenax.Owner("#auth"),
goenax.Tags("Auth"),
goenax.Request[LoginRequest](),
goenax.Response[Session](http.StatusOK),
goenax.Error(http.StatusUnauthorized, "invalid", "wrong email or password"),
))
spec, err := reg.OpenAPI(goenax.Info{Title: "My API", Version: "1.0.0"})
if err != nil {
panic(err)
}
var doc struct {
OpenAPI string `json:"openapi"`
Paths map[string]map[string]any `json:"paths"`
Components struct {
Schemas map[string]any `json:"schemas"`
} `json:"components"`
}
if err := json.Unmarshal(spec, &doc); err != nil {
panic(err)
}
schemas := slices.Sorted(func(yield func(string) bool) {
for name := range doc.Components.Schemas {
if !yield(name) {
return
}
}
})
fmt.Println("openapi:", doc.OpenAPI)
fmt.Println("operations:", len(doc.Paths["/api/login"]))
fmt.Println("schemas:", schemas)
}
Output: openapi: 3.1.0 operations: 1 schemas: [LoginRequest Session]
Index ¶
- Constants
- func Docs(reg *Registry, info Info, opts ...DocsOption) http.Handler
- func NormalizePath(path string) string
- type DocsOption
- type Endpoint
- type ErrorSpec
- type ExtraResponse
- type Info
- type Option
- func Also(status int, desc string) Option
- func AlsoJSON[T any](status int, desc string) Option
- func Compose(opts ...Option) Option
- func DependsOn(ids ...string) Option
- func Description(s string) Option
- func Error(status int, code, message string) Option
- func HTML() Option
- func Header(name string, required bool, desc string) Option
- func NoContent(status int) Option
- func Owner(s string) Option
- func Params[T any]() Option
- func PathParam(name, desc string) Option
- func Query(name string, required bool, desc string) Option
- func Request[T any]() Option
- func RespHeader(name, desc string) Option
- func Response[T any](status int) Option
- func Secured() Option
- func Summary(s string) Option
- func Tags(tags ...string) Option
- type Param
- type Registry
- func (r *Registry) Add(e Endpoint)
- func (r *Registry) Coverage(actual []Route) (undocumented, unmounted []Route)
- func (r *Registry) Endpoints() []Endpoint
- func (r *Registry) Ignore(method, path string)
- func (r *Registry) Lookup(method, path string) (Endpoint, bool)
- func (r *Registry) OpenAPI(info Info) ([]byte, error)
- func (r *Registry) Validate() []ValidationError
- func (r *Registry) ValidateResponse(method, path string, body []byte) error
- type ResponseHeader
- type Route
- type Schema
- type Server
- type UI
- type ValidationError
Examples ¶
Constants ¶
const SpecFile = "openapi.json"
SpecFile is the name the spec is served under, relative to the docs page: a Docs handler mounted at /docs serves the spec at /docs/openapi.json.
Variables ¶
This section is empty.
Functions ¶
func Docs ¶ added in v0.2.0
func Docs(reg *Registry, info Info, opts ...DocsOption) http.Handler
Docs serves the registry as live documentation — the FastAPI /docs idea: the running service is the source of truth for its own contract.
The handler answers two requests:
GET …/openapi.json the OpenAPI document, built from the registry GET … an HTML page rendering that document in the chosen UI
Mount it with an adapter's Docs method (which also keeps the docs routes out of the spec and of Coverage), or by hand on any router.
The spec is built lazily and cached until the registry changes, and is served with an ETag so clients revalidate cheaply. Leave info.Servers empty to make "Try it out" call whichever host served the page — the same binary then works on every environment without configuration.
The UI assets load from the jsDelivr CDN (as FastAPI does). The page needs no server-side knowledge of its own URL: it resolves openapi.json relative to the address the browser is on, so a path-rewriting reverse proxy keeps working.
Exposing the spec reveals every endpoint; guard it in production (auth middleware, internal network only, or simply don't mount it).
Example ¶
Docs serves the live spec and an interactive UI. With an adapter, mount it with Router.Docs instead — that also keeps the docs routes out of the spec.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
goenax "github.com/Lev2098/Goenax"
)
func main() {
reg := goenax.New()
reg.Add(goenax.Define(http.MethodGet, "/api/ping", goenax.Summary("Health check")))
docs := goenax.Docs(reg, goenax.Info{Title: "My API", Version: "1.0.0"}, goenax.WithUI(goenax.Scalar))
mux := http.NewServeMux()
mux.Handle("GET /docs", docs)
mux.Handle("GET /docs/openapi.json", docs)
for _, path := range []string{"/docs", "/docs/openapi.json"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
fmt.Println(path, rec.Code, rec.Header().Get("Content-Type"))
}
}
Output: /docs 200 text/html; charset=utf-8 /docs/openapi.json 200 application/json
func NormalizePath ¶ added in v0.2.0
NormalizePath rewrites a router-native path template into the OpenAPI form the registry stores, so a route declared in its framework's own syntax documents (and validates) correctly:
/users/:id -> /users/{id} (Echo, Gin)
/files/*filepath -> /files/{filepath} (Gin catch-all)
/files/{path...} -> /files/{path} (net/http wildcard)
/posts/{$} -> /posts/ (net/http exact-match marker)
A bare `*` (Echo's unnamed wildcard) has no OpenAPI equivalent and is kept as-is. Paths already in OpenAPI form pass through unchanged.
Example ¶
package main
import (
"fmt"
goenax "github.com/Lev2098/Goenax"
)
func main() {
fmt.Println(goenax.NormalizePath("/users/:id/files/*path"))
fmt.Println(goenax.NormalizePath("/static/{rest...}"))
}
Output: /users/{id}/files/{path} /static/{rest}
Types ¶
type DocsOption ¶ added in v0.2.0
type DocsOption func(*docsConfig)
DocsOption customises a Docs handler.
func WithUI ¶ added in v0.2.0
func WithUI(ui UI) DocsOption
WithUI picks the viewer the docs page renders (default SwaggerUI).
type Endpoint ¶
type Endpoint struct {
Method string
Path string
Summary string
Desc string
Owner string
Tags []string
Request reflect.Type // nil = no request body
Response reflect.Type // nil = empty response (e.g. 204)
Status int // success status code (200/201/204)
Secured bool // true = requires a bearer session
SuccessHTML bool // success renders text/html rather than JSON
Params []Param
RespHeaders []ResponseHeader // headers on the success response
Also []ExtraResponse
Errors []ErrorSpec
DependsOn []string // IDs of endpoints that must run first (QA scenarios)
}
Endpoint is the full contract of a single route: how it is called plus the metadata Validate requires. Request and Response hold the Go types of the bodies (nil when there is none); their JSON schemas are derived from those types when the spec is built.
type ErrorSpec ¶
type ErrorSpec struct {
Status int
Code string // short machine-readable slug, e.g. "validation"
Message string // human-readable example message
}
ErrorSpec is one documented non-2xx outcome of an endpoint.
type ExtraResponse ¶
ExtraResponse is an additional documented response (e.g. a 202 alternative to the primary success). Type is optional — nil means the response has no body.
type Info ¶
type Info struct {
Title string
Version string
Servers []Server
// ErrorType is the Go type of the JSON body every documented Error returns,
// e.g. reflect.TypeFor[apiError](). Nil means the default { "message": … }.
ErrorType reflect.Type
}
Info is the OpenAPI `info` block plus document-wide settings.
type Option ¶
type Option func(*Endpoint)
Option mutates an Endpoint during Define.
This is the functional-options pattern: each helper below returns a small function that sets one field, and Define applies them in order. It keeps declarations readable and lets us add new metadata later without changing every call site.
func Also ¶
Also documents an additional response status alongside the primary success, without a body.
func AlsoJSON ¶
AlsoJSON documents an additional response status that carries a JSON body of type T (e.g. a 202 with a { "status": … } payload).
func Compose ¶
Compose bundles several options into one. It lets a middleware ship its own documentation — e.g. a rate limiter that contributes a 429 response, or a captcha that contributes a header and its rejection — so routes opt into the behaviour and its docs together.
func HTML ¶
func HTML() Option
HTML marks the success response as text/html (e.g. email-link landing pages).
func Params ¶ added in v0.3.0
Params declares an endpoint's path, query and header parameters from the struct the handler binds them into, written as goenax.Params[listQuery]().
type listQuery struct {
Limit int `query:"limit" validate:"max=100" doc:"page size"`
Role string `query:"role" validate:"oneof=admin member"`
}
Each field's type and validate rules become the parameter schema (limit is an integer with maximum 100, role an enum), and its `doc` tag the description. Path parameters are always required; query and header parameters only with validate:"required". Fields without a binding tag are ignored, embedded structs are flattened.
Because the names come from the binding tags, renaming a parameter in the struct renames it in the spec — the drift that hand-written Query(...) calls allow is gone.
Example ¶
Params documents path, query and header parameters from the struct the handler binds — here with Echo's tags (Gin's uri/form work too).
package main
import (
"fmt"
"net/http"
goenax "github.com/Lev2098/Goenax"
)
type ListUsersParams struct {
Org string `param:"org" doc:"organisation slug"`
Limit int `query:"limit" validate:"min=1,max=100" doc:"page size"`
Role string `query:"role" validate:"omitempty,oneof=admin member"`
}
func main() {
ep := goenax.Define(http.MethodGet, "/orgs/{org}/users", goenax.Params[ListUsersParams]())
for _, p := range ep.Params {
fmt.Printf("%-5s %-5s required=%-5v type=%s\n", p.In, p.Name, p.Required, p.Schema.Type)
}
}
Output: path org required=true type=string query limit required=false type=integer query role required=false type=string
func PathParam ¶
PathParam documents a path parameter (always required, must appear as {name} in the route path).
func Query ¶
Query documents a query parameter as a plain string. To document typed parameters from the struct the handler binds, use Params[T].
func Request ¶
Request declares the request body type, written as goenax.Request[fooRequest]().
It is generic so the caller names the type instead of passing a value. reflect.TypeFor[T]() (Go 1.22+) captures that type at compile time, so the schema is bound to the exact struct the handler binds — no annotations, and the compiler guarantees the type exists.
func RespHeader ¶
RespHeader documents a header returned on the success response.
func Response ¶
Response declares the success body type and status, e.g. goenax.Response[sessionResponse](200).
type Param ¶
type Param struct {
Name string
In string // "path", "query" or "header"
Required bool
Desc string
Schema *Schema // nil = a plain string (Query/Header/PathParam); set by Params[T]
}
Param is a path, query or header parameter.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds the declared endpoints. It is safe for concurrent use, so the spec can be served live (see Docs) while routes are still being registered.
func (*Registry) Add ¶
Add records an endpoint. Its method is upper-cased and its path normalised to OpenAPI form (see NormalizePath).
func (*Registry) Coverage ¶
Coverage compares the routes actually registered on a router with the endpoints declared in the registry. A shared route table makes the two match by construction; Coverage catches the case a table can't — a route mounted (or removed) directly, out of band. It returns:
- undocumented: on the router but not in the registry (missing from the spec)
- unmounted: in the registry but never registered (a dead spec entry)
Pass the router's routes via an adapter helper, e.g. echoadapter.Routes(e) or ginadapter.Routes(engine). Routes marked with Ignore are never reported as undocumented. (net/http's ServeMux exposes no route list, so it has no helper — feed it a Route slice you build yourself.)
Example ¶
Coverage diffs the router's real routes against the declared ones. Router syntax (":id") and OpenAPI syntax ("{id}") compare equal.
package main
import (
"fmt"
"net/http"
goenax "github.com/Lev2098/Goenax"
)
func main() {
reg := goenax.New()
reg.Add(goenax.Define(http.MethodGet, "/api/users/{id}"))
reg.Add(goenax.Define(http.MethodGet, "/api/orders"))
reg.Ignore(http.MethodGet, "/healthz")
undocumented, unmounted := reg.Coverage([]goenax.Route{
{Method: http.MethodGet, Path: "/api/users/:id"},
{Method: http.MethodPost, Path: "/api/users"},
{Method: http.MethodGet, Path: "/healthz"},
})
fmt.Println("undocumented:", undocumented)
fmt.Println("unmounted:", unmounted)
}
Output: undocumented: [{POST /api/users}] unmounted: [{GET /api/orders}]
func (*Registry) Ignore ¶ added in v0.2.0
Ignore marks a route as intentionally undocumented — health checks, metrics, the docs routes themselves — so Coverage does not report it.
func (*Registry) Lookup ¶
Lookup finds an endpoint by method and path template. The method is matched case-insensitively and the path may be in router-native form (":id").
func (*Registry) OpenAPI ¶
OpenAPI projects the registry into an OpenAPI 3.1 document and returns it as indented JSON. Map keys (paths, methods, status codes) serialise in a stable order, so the output is deterministic and safe to diff / drift-check.
func (*Registry) Validate ¶
func (r *Registry) Validate() []ValidationError
Validate checks every endpoint against the policy and structural rules, and returns one ValidationError per offending endpoint (nil = all good).
Policy (kept small, not bureaucratic): every endpoint needs a summary, description, owner and tag; mutating methods need at least one error response. Structural: no two endpoints share a method+path, path {tokens} match the declared PathParam names, and no status code is claimed by two different responses (success / Also / Error) — OpenAPI keys responses by status, so one would silently overwrite the other. Several Errors on one status are fine: they are merged into one documented response. Request bodies and the success status are not checked (action POSTs carry no body; the status is auto-defaulted).
Example ¶
Validate is the policy gate: run it in a test and fail CI on any problem.
package main
import (
"fmt"
"net/http"
goenax "github.com/Lev2098/Goenax"
)
func main() {
reg := goenax.New()
reg.Add(goenax.Define(http.MethodDelete, "/api/users/{id}",
goenax.Summary("Delete a user"),
))
for _, problem := range reg.Validate() {
fmt.Println(problem)
}
}
Output: DELETE /api/users/{id}: description; owner; tags; error responses; undeclared path parameter {id}
func (*Registry) ValidateResponse ¶
ValidateResponse checks that a JSON response body conforms to the schema of an endpoint's declared Response type. It closes the gap between what an endpoint *promises* (Response[T]) and what the handler actually returns: call it from a handler test on the real response, and a shape that has drifted from the contract fails the test.
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if err := reg.ValidateResponse("POST", "/api/login", rec.Body.Bytes()); err != nil {
t.Fatalf("response breaks its contract: %v", err)
}
It checks the declared shape (types, required properties, enums) and allows unknown extra properties, so a forward-compatible addition doesn't fail a test.
Example ¶
ValidateResponse checks a real handler response against the declared type — call it from a handler test to catch a response that drifted from its contract.
package main
import (
"fmt"
"net/http"
goenax "github.com/Lev2098/Goenax"
)
type Session struct {
Token string `json:"token"`
Role string `json:"role" validate:"oneof=admin member"`
}
func main() {
reg := goenax.New()
reg.Add(goenax.Define(http.MethodPost, "/api/login", goenax.Response[Session](http.StatusOK)))
fmt.Println(reg.ValidateResponse(http.MethodPost, "/api/login", []byte(`{"token":"t","role":"admin"}`)))
fmt.Println(reg.ValidateResponse(http.MethodPost, "/api/login", []byte(`{"token":"t","role":"root"}`)))
}
Output: <nil> $.role: root is not one of [admin member]: value not in enum
type ResponseHeader ¶
ResponseHeader documents a header returned on the success response.
type Schema ¶
type Schema struct {
Ref string `json:"$ref,omitempty"`
Type string `json:"type,omitempty"`
Format string `json:"format,omitempty"`
Enum []any `json:"enum,omitempty"` // typed to match Type (string / int64 / float64)
Example any `json:"example,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Properties map[string]*Schema `json:"properties,omitempty"`
Items *Schema `json:"items,omitempty"`
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
Required []string `json:"required,omitempty"`
}
Schema is a JSON Schema node — the shared shape behind an endpoint's request and response bodies. It maps directly onto the schema object OpenAPI 3.1 uses. When Ref is set the node is only a reference into components/schemas.
type ValidationError ¶
type ValidationError struct {
Endpoint string // "POST /api/contact"
Problems []string // ["description", "owner", "duplicate route"]
}
ValidationError lists everything wrong with one endpoint — missing metadata, a duplicate route, or a path/parameter mismatch.
func (ValidationError) Error ¶
func (e ValidationError) Error() string
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
adapter
|
|
|
echo
Package echoadapter mounts routes on an Echo router while recording each route's contract in an goenax.Registry — one declaration, two outputs: a live route and a spec entry.
|
Package echoadapter mounts routes on an Echo router while recording each route's contract in an goenax.Registry — one declaration, two outputs: a live route and a spec entry. |
|
gin
Package ginadapter mounts routes on a Gin router while recording each route's contract in an goenax.Registry — one declaration, two outputs.
|
Package ginadapter mounts routes on a Gin router while recording each route's contract in an goenax.Registry — one declaration, two outputs. |
|
nethttp
Package nethttpadapter mounts routes on a net/http ServeMux while recording each route's contract in an goenax.Registry — one declaration, two outputs.
|
Package nethttpadapter mounts routes on a net/http ServeMux while recording each route's contract in an goenax.Registry — one declaration, two outputs. |
|
examples
|
|
|
echo
command
Command echo is a runnable Goenax demo on Echo: a few endpoints declared once, served live, documented at /api/docs.
|
Command echo is a runnable Goenax demo on Echo: a few endpoints declared once, served live, documented at /api/docs. |