Help us shape the future of Go! Take the 2026 Go Developer Survey

goenax

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 17 Imported by: 0

README

Goenax

Code-as-source-of-truth API registry for Go.

Declare each HTTP endpoint once — in the same place you mount it on your router — and Goenax builds an OpenAPI 3.1 document from your Go types. From that one source you generate the Postman collection and the TypeScript types with the standard tools, and Goenax gives you the checks that keep them honest.

Framework-neutral: adapters for Echo, net/http and Gin ship in the box; the core imports none of them.

go get github.com/Lev2098/Goenax

Why

Your API contract already exists in the code. Writing it down a second time — a hand-kept OpenAPI file, a Postman collection, doc comments — creates a copy that drifts the first week a handler changes and the copy doesn't. Annotation tools move the drift into comments the compiler never checks. Goenax takes the contract from the Go types you already have, so the schema can't describe a shape the compiler doesn't know.


Quickstart

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/Lev2098/Goenax"
    echoadapter "github.com/Lev2098/Goenax/adapter/echo"
)

type LoginRequest struct {
    Email    string `json:"email"    validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}
type SessionResponse struct {
    Token string `json:"token"`
}

e := echo.New()
reg := goenax.New()
api := echoadapter.New(e.Group("/api"), reg, "/api")

api.POST("/login", loginHandler, []goenax.Option{
    goenax.Summary("Exchange credentials for a session"),
    goenax.Owner("#team"),
    goenax.Tags("Auth"),
    goenax.Request[LoginRequest](),
    goenax.Response[SessionResponse](http.StatusOK),
    goenax.Error(http.StatusUnauthorized, "invalid", "wrong email or password"),
}, authMiddleware)

// one call did two things: mounted the live route on Echo,
// and recorded its contract in reg.

spec, _ := reg.OpenAPI(goenax.Info{
    Title: "My API", Version: "1.0.0",
    Servers: []goenax.Server{{URL: "http://localhost:8080"}},
})

To emit the spec in CI without booting the server, keep the declarations in a table that both the live mount and a handler-free Spec() consume (the option sets don't need handlers) — then a tiny cmd prints Spec().OpenAPI(...).


The declaration API

Option Documents
Summary(s) · Description(s) human text
Owner(s) owning team/channel — required by Validate
Tags(t...) grouping (OpenAPI tags, Postman folders)
Request[T]() request body type
Response[T](status) success body type + status
NoContent(status) success with no body
HTML() success renders text/html
Also(status, desc) · AlsoJSON[T](status, desc) an extra status, without / with a body
RespHeader(name, desc) a header on the success response
Error(status, code, msg) a documented failure
Query / Header / PathParam parameters
Secured() requires a bearer session
DependsOn(ids...) endpoints a QA run must call first
Compose(opts...) bundle options (e.g. a middleware's own docs)

Request[T] / Response[T] are generic: reflect.TypeFor[T]() binds the schema to the exact struct at compile time — no annotations.


Schema derivation

Goenax reflects over the request/response types and reads the json and go-playground validate tags:

Go OpenAPI schema
primitives string / integer / boolean / number
*T, or json:",omitempty" optional
validate:"required" required
validate:"email" / "uuid" / "url" format
validate:"min/max/len" minLength/maxLength or minimum/maximum
validate:"oneof=a b c" enum (typed: oneof=1 2 on an int is [1, 2])
validate:"dive,…" rules after dive apply to array items / map values
example:"…" example, typed to the field (example:"42" on an int is 42); else a deterministic default
time.Time / []T / map[string]T date-time / array / object with additionalProperties
named struct a $ref into components/schemas (defined once, shared)

It is cycle-safe and gives every leaf a deterministic example, so a generated Postman collection is stable across runs.

A field without omitempty that isn't a pointer is required — in requests as well as responses. Make an optional request field a pointer or omitempty.

Every Error documents the same JSON body — { "message": "…" } by default. If your API returns something else, give its type once:

reg.OpenAPI(goenax.Info{Title: "My API", Version: "1.0.0",
    ErrorType: reflect.TypeFor[APIError]()})

Adapters

Same shape for every framework — New, Group, Handle/GET/POST/PUT/ PATCH/DELETE, Docs. Each mounts the route and records the contract. Only the adapter imports the framework.

echoadapter.New(e.Group("/api"), reg, "/api")   // pass the already-prefixed Echo group
nethttpadapter.New(mux, reg, "/api")             // *http.ServeMux (Go 1.22+ patterns)
ginadapter.New(engine, reg, "/api")              // pass the unprefixed router; New creates the group

Write paths in the framework's own syntax — /users/:id on Echo/Gin, /users/{id} on net/http. The registry stores them in OpenAPI form (/users/{id}; see goenax.NormalizePath), so PathParam("id", …), the spec and Coverage all line up.

Middleware uses each framework's native type. echoadapter.Routes(e) / ginadapter.Routes(engine) list the registered routes for Coverage (below).


Live docs (/docs)

Like FastAPI, the running service can serve its own contract — the spec comes from the same registry the routes were mounted through, so it always describes exactly what this build serves.

api.Docs("/docs", goenax.Docs(reg, goenax.Info{
    Title:   "My API",
    Version: version, // e.g. set via -ldflags, so /docs shows which build answers
}), requireStaff)
  • GET /api/docs — interactive UI; GET /api/docs/openapi.json — the spec.
  • UI: goenax.WithUI(goenax.SwaggerUI) (default), goenax.Scalar or goenax.Redoc. Assets load from the jsDelivr CDN.
  • The spec is built lazily, cached until the registry changes, and served with an ETag.
  • Leave Info.Servers empty: "Try it out" then calls whichever host served the page, so one binary works on every environment.
  • The docs routes are not part of the spec and are ignored by Coverage. Use reg.Ignore(method, path) for other intentionally undocumented routes (health checks, metrics).
  • The spec reveals every endpoint. In production, guard it with middleware or don't mount it (if cfg.DocsEnabled { … }).

Without an adapter, goenax.Docs is a plain http.Handler: mount it on path and path + "/openapi.json".

For one portal across many services, let the portal pull each service's /docs/openapi.json (Swagger UI urls, Scalar, Backstage) — the service stays the source of truth.


Keeping it honest

Goenax gives you the checks; wire them into your tests and CI.

  • reg.Validate() — returns problems per endpoint: missing summary/description/owner/tag, a mutating method with no error response, a duplicate method+path, a path {token} that doesn't match a declared PathParam, a status code claimed by two responses (e.g. Also(200) next to a 200 success), or two routes producing the same operationId (/a-b and /a_b). Several Errors on one status are fine — they merge. Fail CI on a non-empty result and documentation becomes a build requirement, not a reviewer's ask.
  • reg.ValidateResponse(method, path, body) — validates a real response against its declared Response[T] schema. Call it from a handler test and a handler that returns the wrong shape fails — closing the gap between what an endpoint promises and what it returns.
  • reg.Coverage(actual) — diffs the routes registered on the router against the declared endpoints, flagging routes that are undocumented or documented but never mounted.
  • Regenerate & diff. Commit the generated spec (and Postman / TS) and, in CI, regenerate them and diff against the committed files — a change that isn't reflected fails the build.
  • Breaking changes. Diff the spec against your baseline with oasdiff and fail on a breaking change.

Postman and TypeScript come from the emitted OpenAPI via openapi-to-postman and openapi-typescript — Goenax doesn't reinvent them.


Changelog

v0.2.0 — 2026-10-07

New

  • Live docs — goenax.Docs(reg, info) serves the spec at …/openapi.json plus a Swagger UI / Scalar / Redoc page; mount it with the adapters' Docs method. See Live docs.
  • reg.Ignore(method, path) keeps health checks, metrics and the docs routes out of Coverage.
  • goenax.NormalizePath — router syntax (:id, *file, {p...}) to OpenAPI ({id}).
  • Info.ErrorType — document your own error body instead of { "message" }.
  • validate:"dive,…" rules are applied to array items / map values.
  • PUT and PATCH helpers on every adapter.
  • Registry is safe for concurrent use.

Fixed

  • Gin: a nested Group mounted routes with the prefix applied twice (/v1/api/v1/…) while the spec said /api/v1/….
  • Echo/Gin: :id path params were recorded verbatim — an invalid OpenAPI path, a false Validate error, and a Coverage mismatch. They are now {id}.
  • oneof on a number field produced a string enum, so ValidateResponse rejected every valid value.
  • Several Errors with one status overwrote each other; their descriptions now merge, and Validate flags a status claimed by two different responses.
  • ValidateResponse accepted 1.5 for an integer field.
  • Lookup was case-sensitive on the method.
  • operationId contained { }; Validate now also flags operationId collisions.
  • example tags on number/bool fields were emitted as strings.

Breaking

  • Schema.Enum is []any (was []string) so enums carry the field's type.
  • Registry.Add stores the method upper-cased and the path in OpenAPI form.
  • ginadapter.New creates the prefixed group itself — pass the unprefixed router, New(engine, reg, "/api") (as the README always showed).

License

MIT — see LICENSE.

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).

Index

Constants

View Source
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).

func NormalizePath added in v0.2.0

func NormalizePath(path string) string

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.

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.

func Define

func Define(method, path string, opts ...Option) Endpoint

Define assembles an Endpoint from a method, path, and options. Status defaults to 200 unless an option overrides it. Define does no validation — that is Validate's job; here we only build the declaration.

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

type ExtraResponse struct {
	Status int
	Desc   string
	Type   reflect.Type
}

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

func Also(status int, desc string) Option

Also documents an additional response status alongside the primary success, without a body.

func AlsoJSON

func AlsoJSON[T any](status int, desc string) Option

AlsoJSON documents an additional response status that carries a JSON body of type T (e.g. a 202 with a { "status": … } payload).

func Compose

func Compose(opts ...Option) Option

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 DependsOn

func DependsOn(ids ...string) Option

DependsOn records endpoint IDs that must be called before this one.

func Description

func Description(s string) Option

Description sets the longer description.

func Error

func Error(status int, code, message string) Option

Error documents one non-2xx outcome.

func HTML

func HTML() Option

HTML marks the success response as text/html (e.g. email-link landing pages).

func Header(name string, required bool, desc string) Option

Header documents a request header (e.g. X-Recaptcha-Token).

func NoContent

func NoContent(status int) Option

NoContent declares a success with an empty body, e.g. goenax.NoContent(204).

func Owner

func Owner(s string) Option

Owner sets the owning team/channel (e.g. "#team").

func PathParam

func PathParam(name, desc string) Option

PathParam documents a path parameter (always required, must appear as {name} in the route path).

func Query

func Query(name string, required bool, desc string) Option

Query documents a query parameter.

func Request

func Request[T any]() Option

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

func RespHeader(name, desc string) Option

RespHeader documents a header returned on the success response.

func Response

func Response[T any](status int) Option

Response declares the success body type and status, e.g. goenax.Response[sessionResponse](200).

func Secured

func Secured() Option

Secured marks the endpoint as requiring a bearer session. Unmarked endpoints are public (they emit an empty security requirement).

func Summary

func Summary(s string) Option

Summary sets the one-line summary.

func Tags

func Tags(tags ...string) Option

Tags appends grouping tags (folders in Postman, tags in OpenAPI).

type Param

type Param struct {
	Name     string
	In       string // "query"
	Required bool
	Desc     string
}

Param is a query (or, later, path) 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 New

func New() *Registry

func (*Registry) Add

func (r *Registry) Add(e Endpoint)

Add records an endpoint. Its method is upper-cased and its path normalised to OpenAPI form (see NormalizePath).

func (*Registry) Coverage

func (r *Registry) Coverage(actual []Route) (undocumented, unmounted []Route)

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.)

func (*Registry) Endpoints

func (r *Registry) Endpoints() []Endpoint

func (*Registry) Ignore added in v0.2.0

func (r *Registry) Ignore(method, path string)

Ignore marks a route as intentionally undocumented — health checks, metrics, the docs routes themselves — so Coverage does not report it.

func (*Registry) Lookup

func (r *Registry) Lookup(method, path string) (Endpoint, bool)

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

func (r *Registry) OpenAPI(info Info) ([]byte, error)

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).

func (*Registry) ValidateResponse

func (r *Registry) ValidateResponse(method, path string, body []byte) error

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.

type ResponseHeader

type ResponseHeader struct {
	Name string
	Desc string
}

ResponseHeader documents a header returned on the success response.

type Route

type Route struct {
	Method string
	Path   string
}

Route is a method + path pair — the unit of comparison for Coverage.

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.

func SchemaFor

func SchemaFor(t reflect.Type) Schema

SchemaFor builds an inline Schema from a Go type (no $ref). It is cycle-safe: a self-referential type stops at a bare object rather than recursing forever.

type Server

type Server struct {
	URL         string
	Description string
}

Server is one OpenAPI server entry.

type UI added in v0.2.0

type UI string

UI selects the interactive viewer the docs page renders.

const (
	SwaggerUI UI = "swagger" // Swagger UI — "Try it out" on every operation (default)
	Scalar    UI = "scalar"  // Scalar — modern reference with a built-in client
	Redoc     UI = "redoc"   // Redoc — read-only, three-panel reference
)

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

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL