https://github.com/mrz1836/go-actions
🎬 Typed HTTP action framework with OpenAPI 3.1 generation for chi
https://github.com/mrz1836/go-actions
actions chi go golang http router
Last synced: about 12 hours ago
JSON representation
🎬 Typed HTTP action framework with OpenAPI 3.1 generation for chi
- Host: GitHub
- URL: https://github.com/mrz1836/go-actions
- Owner: mrz1836
- License: mit
- Created: 2026-06-24T19:44:54.000Z (2 days ago)
- Default Branch: master
- Last Pushed: 2026-06-25T02:09:57.000Z (1 day ago)
- Last Synced: 2026-06-25T02:10:54.236Z (1 day ago)
- Topics: actions, chi, go, golang, http, router
- Language: Go
- Homepage:
- Size: 1.05 MB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: .github/CONTRIBUTING.md
- Funding: .github/FUNDING.yml
- License: LICENSE
- Code of conduct: .github/CODE_OF_CONDUCT.md
- Codeowners: .github/CODEOWNERS
- Security: .github/SECURITY.md
- Support: .github/SUPPORT.md
- Agents: .github/AGENTS.md
Awesome Lists containing this project
README
# 🎬 go-actions
**Typed HTTP actions for Go — one declaration, a handler and an OpenAPI 3.1 contract that can't drift**
### Project Navigation
📦 Installation
⚡ Quick Start
🧪 Examples & Tests
📚 Documentation
🛠️ Code Standards
📊 Benchmarks
🤖 AI Usage
⚖️ License
👥 Maintainers
## 🧩 About
**go-actions** is a typed HTTP action framework with **OpenAPI 3.1 generation** for
[chi](https://github.com/go-chi/chi). You declare a route **once** as a typed
`Action[Req, Resp]`, and a `Registry` turns each declaration — all from the **same
reflected types** — into:
- an `http.HandlerFunc` (decode → validate → handle → encode),
- a JSON Schema 2020-12,
- an OpenAPI 3.1 document (JSON **and** YAML), and
- a browsable HTML/Markdown action index.
Because every artifact derives from the same types, the **published contract cannot
drift from runtime behavior**. `Freeze()` enforces invariants at startup — unique IDs,
unique method+path, and documented statuses — so a misconfigured route fails on boot,
not in production.
- **One declaration, many artifacts** — handler, schema, OpenAPI, and docs all generated from one typed struct.
- **Production-safe by default** — panic recovery, a 1 MiB request-body cap, request-id propagation, and JSON `404`/`405` responses are on out of the box, every one overridable.
- **Composable middleware** — registry-wide (`WithMiddleware`) and per-action (`Action.Middleware`) using the standard chi/`net-http` signature, for auth, CORS, logging, and rate limiting.
- **Observability hook** — one `WithObserver` callback receives each request's action id, status, latency, and error — the seam for access logs, metrics, and tracing.
- **Documented auth** — declare OpenAPI `securitySchemes` and per-operation `security` (with `BearerAuth`/`APIKeyAuth` helpers) so the contract states how to authenticate.
- **Pluggable error mapping** — decouple your domain errors from the wire shape with an `ErrorMapper`.
- **Self-documenting** — serve `/openapi.json`, `/openapi.yaml`, and a browsable `/_actions` index straight from the registry.
- **Struct-tag validation, with an escape hatch** — `required`, `min`, `max`, `oneof`, `uuid`, `email`, `e164`, `rfc3339` (the same tags feed the JSON Schema), plus a `Validatable` interface for rules a tag can't express.
- **No domain coupling** — the core imports only the standard library, `go-chi/chi/v5`, `google/uuid`, and `gopkg.in/yaml.v3`.
> Why it matters: in 2026, your API contract is consumed by SDK generators, API
> gateways, and AI agents calling tools. A contract generated from the code that
> actually serves traffic is one you never have to hand-reconcile.
## 📦 Installation
**go-actions** requires a [supported release of Go](https://golang.org/doc/devel/release.html#policy).
```shell script
go get -u github.com/mrz1836/go-actions
```
Get the [MAGE-X](https://github.com/mrz1836/mage-x) build tool for development:
```shell script
go install github.com/mrz1836/mage-x/cmd/magex@latest
```
## ⚡ Quick Start
### 1. Declare an action
An `Action[Req, Resp]` declares one route. Request fields bind from the JSON body or
from `path`/`query`/`header` tags; `validate` tags are enforced before your handler runs.
```go
package main
import (
"context"
"net/http"
"github.com/mrz1836/go-actions"
)
type createUserReq struct {
Name string `json:"name" validate:"required,min=1,max=64"`
Email string `json:"email" validate:"required,email"`
}
type user struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func createUser() actions.Action[createUserReq, actions.Created[user]] {
return actions.Action[createUserReq, actions.Created[user]]{
ID: "users.create",
Method: http.MethodPost,
Path: "/users",
Summary: "Create a user",
Tags: []string{"users"},
Statuses: []actions.StatusDoc{
{Code: http.StatusCreated, Description: "the created user"},
{Code: http.StatusUnprocessableEntity, Description: "invalid body", Error: true},
},
Handle: func(_ context.Context, req createUserReq) (actions.Created[user], error) {
u := user{ID: "u_1", Name: req.Name, Email: req.Email}
return actions.Created[user]{Body: u}, nil
},
}
}
```
Response envelopes set the documented status: `actions.Empty` → 204,
`actions.Created[T]` → 201, `actions.Accepted[T]` → 202. Returning any other value
encodes as 200. For full control, return `actions.Response[T]` to set a custom status
and response headers (e.g. `Cache-Control`/`ETag`), `actions.Page[T]` for a
conventional cursor-paginated list body, or `actions.List[T]` for a non-paginated
collection — `{"items": [...], "meta": {"total": N}}`. Build the latter with
`actions.NewList(items)`, which sets the total and normalizes a nil slice to `[]`:
```go
Handle: func(ctx context.Context, _ listReq) (actions.List[user], error) {
return actions.NewList(store.all()), nil // 200 {"items":[...],"meta":{"total":N}}
}
```
Path, query, and header parameters bind by struct tag (`path:`, `query:`,
`header:`). Scalars convert automatically — `string`, `bool`, the integer and
float kinds, and `time.Time` (parsed as RFC3339 or a bare `2006-01-02` date; a
malformed value yields a 422). Any of these may be a pointer, so an absent
optional parameter stays `nil` rather than a zero value:
```go
type listReq struct {
Active *bool `json:"-" query:"active"` // nil when ?active= is absent
Since *time.Time `json:"-" query:"since"` // RFC3339; 422 if malformed
Limit int `json:"-" query:"limit" validate:"max=100"`
}
```
### 2. Register, freeze, and mount
```go
func main() {
reg := actions.NewRegistry(actions.WithInfo(
"Users API",
"Manage users.",
"1.0.0",
))
actions.Register(reg, createUser())
reg.Freeze() // validates declarations and builds the contract artifacts
http.Handle("/", reg.Handler())
_ = http.ListenAndServe(":8080", nil)
}
```
`Register` is the only typed seam — after `Freeze()` the registry is sealed and further
`Register` calls panic. `Handler()` returns an `http.Handler` mounting every action plus
the self-documentation endpoints.
### 3. Serve the contract
`Handler()` mounts three self-documenting endpoints automatically:
| Endpoint | Returns |
| ---------------- | --------------------------------------------------- |
| `/openapi.json` | the OpenAPI 3.1 document as JSON |
| `/openapi.yaml` | the same document as YAML |
| `/_actions` | a browsable HTML index (Markdown via `Accept`) |
The `/_actions` index ships in core and is always on — no build tag required. You can
also reach the raw bytes directly with `reg.OpenAPIJSON()` and `reg.OpenAPIYAML()` (for
example, to write a committed snapshot).
Pluggable error handling
Handlers return ordinary Go errors. An `ErrorMapper` translates them into the
transport-level `APIError`, decoupling the framework from your domain error model:
```go
type APIError struct {
Status int
Code string
Message string
Fields []FieldError
}
type ErrorMapper func(error) APIError
```
Install one with `WithErrorMapper`:
```go
reg := actions.NewRegistry(actions.WithErrorMapper(func(err error) actions.APIError {
if errors.Is(err, ErrNotFound) {
return actions.APIError{
Status: http.StatusNotFound,
Code: actions.CodeNotFound,
Message: "resource not found",
}
}
return actions.APIError{
Status: http.StatusInternalServerError,
Code: actions.CodeInternal,
Message: "an internal error occurred",
}
}))
```
The default mapper passes an `*APIError` through unchanged (so handlers may return one
directly) and maps every other error to a redacted 500, ensuring internal detail never
reaches the wire. The error envelope is always
`{"error": ..., "code": ..., "request_id": ...}`.
**foundationx adapter** — the optional `foundationx` sub-package provides a ready-made
`ErrorMapper` that wires the [`go-foundation`](https://github.com/mrz1836/go-foundation)
error model (`*ValidationError` → 422, `ErrNotFound` → 404). It lives in its own package
so consumers that do not use `go-foundation` never pull it into their module graph:
```go
import "github.com/mrz1836/go-actions/foundationx"
reg := actions.NewRegistry(actions.WithErrorMapper(foundationx.NewErrorMapper()))
```
Registry options
| Option | Effect |
| ------------------------------- | ------------------------------------------------------------------- |
| `WithInfo(title, desc, version)`| Sets the OpenAPI `info` block; the title also names the `_actions` index. |
| `WithErrorMapper(mapper)` | Installs a custom error mapper (replaces the default generic one). |
| `WithStripPrefix(prefix)` | Strips a namespace prefix from each action's `Path` when routing (e.g. when the registry is mounted under that prefix). |
| `WithMiddleware(mw...)` | Registry-wide middleware applied to every route (actions, self-docs, `404`/`405`). |
| `WithMaxBodyBytes(n)` | Caps the request body (default 1 MiB; `0` disables). Over-limit ⇒ `413`. |
| `WithObserver(fn)` | Per-request hook with action id, status, latency, and error. |
| `WithRequestIDGenerator(fn)` | Overrides how a correlation id is minted when none is inbound (default UUIDv4). |
| `WithNotFoundHandler(h)` / `WithMethodNotAllowedHandler(h)` | Override the JSON `404` / `405` defaults. |
| `WithSecurityScheme(name, scheme)` | Declares an OpenAPI security scheme (`BearerAuth`/`APIKeyAuth` helpers). |
| `WithSecurity(reqs...)` | Sets registry-wide default security requirements. |
| `WithServers(servers...)` | Sets the OpenAPI `servers[]` block. |
| `WithOpenAPIVersion(v)` | Declares the dialect: `"3.1.0"` (default) or `"3.0.3"`. |
Production middleware & safety
Every registry ships a built-in middleware chain wrapping the typed pipeline — all on by
default and overridable:
- **Panic recovery** — a panicking handler becomes a logged, redacted `500` (and a non-nil
`Observation.Err`); the connection is never torn down.
- **Request-body cap** — bodies over `WithMaxBodyBytes` (default 1 MiB) are rejected with
`413`. Pass `0` to disable.
- **Request-id propagation** — an inbound `X-Request-ID` / `X-Amzn-Request-Id` is reused,
otherwise one is generated; it is placed in the context (`actions.RequestIDFromContext`),
echoed on the response, and included in every error envelope.
- **JSON `404` / `405`** — unmatched routes and wrong methods return the standard error
envelope, not chi's plain text.
Per-action knobs live on the `Action` struct:
```go
actions.Action[Req, Resp]{
// ...
Middleware: []actions.Middleware{authOnly}, // wraps just this route
Timeout: 2 * time.Second, // ctx deadline → 504 on overrun
Security: []actions.SecurityRequirement{{"BearerAuth": {"admin"}}},
Deprecated: true, // marked deprecated in OpenAPI
}
```
Wire observability with one hook:
```go
reg := actions.NewRegistry(actions.WithObserver(func(o actions.Observation) {
slog.Info("request",
"action", o.ActionID, "status", o.Status, "request_id", o.RequestID,
"ms", o.Duration.Milliseconds(), "err", o.Err)
}))
```
Auth & OpenAPI security
Declare security schemes once; reference them registry-wide or per action. They surface in
the generated contract under `components.securitySchemes` and `security`:
```go
reg := actions.NewRegistry(
actions.WithSecurityScheme("BearerAuth", actions.BearerAuth("JWT")),
actions.WithSecurityScheme("ApiKeyAuth", actions.APIKeyAuth("header", "X-API-Key")),
actions.WithSecurity(actions.SecurityRequirement{"ApiKeyAuth": nil}), // default for all ops
actions.WithServers(actions.Server{URL: "https://api.example.com", Description: "production"}),
)
```
go-actions does not authenticate requests itself — wire your auth as `WithMiddleware` or a
per-action `Middleware`; the security blocks document *how* clients authenticate so SDK
generators and gateways configure it correctly.
Validation tags
The `validate` struct tag drives **both** request validation and the generated JSON
Schema constraints. Supported rules:
`required`, `min=N`, `max=N`, `oneof=a b c`, `uuid`, `email`, `e164`, `rfc3339`.
For strings and slices, `min`/`max` bound the length; for numbers they bound the value.
Format rules (`uuid`, `email`, etc.) are skipped on an empty value — use `required` to
reject emptiness.
For rules a tag can't express, a request type may implement `Validatable`
(`Validate() error`); it runs after the tag rules and its field details merge into the
`422` response.
## 📚 Documentation
- **API Reference** – Dive into the godocs at [pkg.go.dev/github.com/mrz1836/go-actions](https://pkg.go.dev/github.com/mrz1836/go-actions)
- **Benchmarks** – Check the latest numbers in the [benchmarks](#-benchmarks) section
- **Test Suite** – Review the [unit tests](action_test.go) (powered by [`testify`](https://github.com/stretchr/testify))
- **Examples** – Browse the runnable pet-store API in [`examples/`](examples)
Repository Features
This repository includes 25+ built-in features covering CI/CD, security, code quality, developer experience, and community tooling.
**[View the full Repository Features list →](.github/docs/repository-features.md)**
Library Deployment
This project uses [goreleaser](https://github.com/goreleaser/goreleaser) for streamlined binary and library deployment to GitHub. To get started, install it via:
```bash
brew install goreleaser
```
The release process is defined in the [.goreleaser.yml](.goreleaser.yml) configuration file.
Then create and push a new Git tag using:
```bash
magex version:bump push=true bump=patch branch=master
```
This process ensures consistent, repeatable releases with properly versioned artifacts and metadata.
Pre-commit Hooks
Set up the Go-Pre-commit System to run the same formatting, linting, and tests defined in [AGENTS.md](.github/AGENTS.md) before every commit:
```bash
go install github.com/mrz1836/go-pre-commit/cmd/go-pre-commit@latest
go-pre-commit install
```
The system is configured via modular env files in [`.github/env/`](.github/env/README.md) and provides 17x faster execution than traditional Python-based pre-commit hooks. See the [complete documentation](http://github.com/mrz1836/go-pre-commit) for details.
GitHub Workflows
All workflows are driven by modular configuration in [`.github/env/`](.github/env/README.md) — no YAML editing required.
**[View all workflows and the control center →](.github/docs/workflows.md)**
Updating Dependencies
To update all dependencies (Go modules, linters, and related tools), run:
```bash
magex deps:update
```
This command ensures all dependencies are brought up to date in a single step, including Go modules and any tools managed by [MAGE-X](https://github.com/mrz1836/mage-x). It is the recommended way to keep your development environment and CI in sync with the latest versions.
Build Commands
View all build commands
```bash script
magex help
```
## 🧪 Examples & Tests
All unit tests run via [GitHub Actions](https://github.com/mrz1836/go-actions/actions) and use [Go version 1.25.x](https://go.dev/doc/go1.25). View the [configuration file](.github/workflows/fortress.yml).
The [`examples/`](examples) directory contains a runnable pet-store API:
- [`examples/main.go`](examples/main.go) — a server declaring actions, mounting `Handler()`, and serving `/openapi.json`, `/openapi.yaml`, and `/_actions`.
- [`examples/openapi-snapshot`](examples/openapi-snapshot) — a contract-drift guard that writes or `check`s a committed OpenAPI snapshot (wire `check` into CI to fail the build when the generated contract drifts).
- [`examples/petstore`](examples/petstore) — the shared registry both examples use.
Run the example server:
```bash script
cd examples && go run .
# then browse http://localhost:8080/_actions
```
The `actiontest` helper exercises actions through the real pipeline or directly:
```go
import "github.com/mrz1836/go-actions/actiontest"
// Spin up a test server running the full decode/validate/encode pipeline:
srv := actiontest.NewServer(t, reg)
// Or invoke a handler directly, bypassing decode/validate/encode:
resp, err := actiontest.Invoke(t, createUser(), createUserReq{Name: "Ada", Email: "ada@example.com"})
```
Run all tests (fast):
```bash script
magex test
```
Run all tests with race detector (slower):
```bash script
magex test:race
```
## 📊 Benchmarks
Run the Go benchmarks:
```bash script
magex bench
```
> Benchmarks cover the hot path — request decoding, validation, and response encoding through the typed pipeline — plus the one-time OpenAPI contract generation.
## 🛠️ Code Standards
Read more about this Go project's [code standards](.github/CODE_STANDARDS.md).
## 🤖 AI Usage & Assistant Guidelines
Read the [AI Usage & Assistant Guidelines](.github/tech-conventions/ai-compliance.md) for details on how AI is used in this project and how to interact with the AI assistants.
## 👥 Maintainers
| [
](https://github.com/mrz1836) |
|:-----------------------------------------------------------------------------------------------------------:|
| [MrZ](https://github.com/mrz1836) |
## 🤝 Contributing
View the [contributing guidelines](.github/CONTRIBUTING.md) and please follow the [code of conduct](.github/CODE_OF_CONDUCT.md).
### How can I help?
All kinds of contributions are welcome :raised_hands:!
The most basic way to show your support is to star :star2: the project, or to raise issues :speech_balloon:.
You can also support this project by [becoming a sponsor on GitHub](https://github.com/sponsors/mrz1836) :clap:
or by making a [**bitcoin donation**](https://mrz1818.com/?tab=tips&utm_source=github&utm_medium=sponsor-link&utm_campaign=go-actions&utm_term=go-actions&utm_content=go-actions) to ensure this journey continues indefinitely! :rocket:
[](https://github.com/mrz1836/go-actions/stargazers)
## 📝 License
[](LICENSE)