tyche

module
v1.0.2 Latest Latest
Warning

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

Go to latest
Published: Aug 27, 2026 License: Apache-2.0

README

tyche

Typed Go HTTP handlers that generate their own OpenAPI spec. Run over any router you bring — with optional zero-reflection codecs.

CI Go Reference Release Go Version License Stars


Write a handler as func(ctx, *In) (*Out, error). tyche derives the OpenAPI operation from the types, and binds / validates / serializes via reflection — so it runs with no build step. Optionally, tyche generate emits that binding code ahead of time for a reflection-free fast path (like sqlc, but for HTTP I/O). Routing is delegated to a pluggable Adapter — the stdlib net/http.ServeMux by default, or chi / gin / anything you wire up.

Why tyche

  • One handler signature, full OpenAPI. func(ctx, *In) (*Out, error) is all you write — the spec is derived from the types.
  • No build step required. Reflection binding means go run . works out of the box. Codegen is an optimization, not a prerequisite.
  • Bring your own router. tyche owns binding/validation/serialization/OpenAPI and deliberately does not own routing. The stdlib adapter is the zero-dependency default; any router plugs in via a ~40-line Adapter.
  • sqlc-style codegen when you want speed. tyche generate emits byte-level codecs with no runtime reflection — byte-identical output to the reflection path.
  • Stdlib-only core. The core module depends only on the standard library. Third-party routers are opt-in glue you supply.
  • Production-shaped defaults. {"data": …} success envelope, RFC 9457 application/problem+json errors, SSE streaming, content negotiation, per-route body limits, instrumentation seam.

Quick start

mkdir myapi && cd myapi
go mod init github.com/me/myapi
go get github.com/webdeveloperben/tyche
go install github.com/webdeveloperben/tyche/cmd/tyche@latest

Drop this in main.go:

package main

import (
	"context"
	"net/http"

	"github.com/webdeveloperben/tyche/server"
	"github.com/webdeveloperben/tyche/server/apidocs"
)

type GetUserInput struct {
	ID string `path:"id"`
}

type GetUserOutput struct {
	Body struct {
		ID   string `json:"id"`
		Name string `json:"name"`
	}
}

func getUser(ctx context.Context, in *GetUserInput) (*GetUserOutput, error) {
	out := &GetUserOutput{}
	out.Body.ID, out.Body.Name = in.ID, "Ada"
	return out, nil
}

func main() {
	api := server.NewAPI(server.NewServeMuxAdapter(), server.APIConfig{
		OpenAPI:             server.OpenAPIInfo{Title: "Example API", Version: "1.0.0"},
		MaxRequestBodyBytes: 10 << 20,
	})

	v1 := api.Group("/api")

	server.Register(v1, server.Operation{
		OperationID: "get-user",
		Method:      http.MethodGet,
		Path:        "/users/:id",
	}, getUser)

	_ = apidocs.Mount(api, apidocs.Config{
		SpecPath: "/openapi.json",
		UIs: []apidocs.UIMount{
			{Path: "/docs", Renderer: apidocs.Scalar()},
		},
	})

	_ = http.ListenAndServe(":8080", api)
}
tyche init --module github.com/me/myapi/client --yes
go run .            # curl localhost:8080/api/users/u1
tyche generate      # optional: emit zero-reflection codecs for a speed-up

The generated zz_server_routes_gen.go is registered in an init() and replaces the reflection path for that route. Both paths produce byte-identical responses — delete the generated file and the reflection path takes over with no other edits.

Performance

Measured on Apple M3 Pro, tyche over the stdlib ServeMuxAdapter (task benchmark:comparison):

Benchmark tyche (stdlib) chi gin huma
Static route 453 ns · 3 allocs 320 · 2 309 · 2 992 · 8
Param route 482 ns · 4 allocs 314 · 5 296 · 3 1076 · 10
Body (bind+validate+serialize) 1370 ns · 13 allocs 2334 · 19 2435 · 19 4708 · 41
Nested body 1780 ns · 22 allocs 1870 · 23 1931 · 23 4269 · 48

Routing cost is your adapter's cost; the generated codec is where tyche pays off — ~1.7× faster than chi/gin and ~3× faster than huma on real endpoints, adapter-independent. Treat these as regression baselines, not a definitive shootout.


Documentation

The full reference is below in collapsible sections — click to expand. The Quick Start above is the whole shape; everything else is depth on top of it.

Install — library, CLI, prebuilt binaries, contributing

There are two installs, depending on which side of the API you're on.

Using tyche in your own project — you need both the Go packages (for imports to resolve) and the CLI binary (to drive init / generate / client / test / build / run).

# 1. Add tyche to your go.mod:
go get github.com/webdeveloperben/tyche

# 2. Install the tyche CLI once. It lives on $GOPATH/bin and works from
#    any project directory:
go install github.com/webdeveloperben/tyche/cmd/tyche@latest

# 3. In your project, scaffold the config (one file, committed to git):
cd myproject
tyche init --module github.com/me/myproject/client --yes
tyche generate            # emits server codecs, if you have any
tyche client              # regenerates the typed client from spec

The CLI is a single binary; you do not add it to your project's go.mod. The libraries (server, server/apidocs, server/plugins, clientgen, etc.) are regular Go packages you import and version through your go.mod.

Prefer a prebuilt binary? Every release ships static binaries for macOS, Linux, and Windows (amd64/arm64) on the Releases page — no Go toolchain required:

# Example: macOS arm64. Swap in the version + platform you need.
VER=1.2.3
curl -fsSLO "https://github.com/webdeveloperben/tyche/releases/download/v${VER}/tyche_${VER}_darwin_arm64.tar.gz"
tar -xzf "tyche_${VER}_darwin_arm64.tar.gz"
sudo mv tyche /usr/local/bin/
tyche version

Verify it against checksums.txt on the release page.

Working on tyche itself:

git clone https://github.com/webdeveloperben/tyche
cd tyche
mise install              # installs the pinned Go version
lefthook install          # sets up the pre-commit hook
task tests                # full suite in a generated worktree
go build -o ./bin/tyche ./cmd/tyche
./bin/tyche --help
task verify:cli           # smoke-test the CLI surface

Cross-router benchmarks live in benchmarks/comparison/ as their own sub-module so chi/gin/huma don't leak into your tyche build. Run them with cd benchmarks/comparison && go test -bench=..

See CONTRIBUTING.md for the day-to-day dev loop, the test shapes, and the rules for breaking changes.

Build your own API — the literal end-to-end flow

The Quick Start above is the whole shape; this is the end-to-end flow from a fresh directory to a running server with generated codecs.

# 1. Create a project.
mkdir myapi && cd myapi
go mod init github.com/me/myapi

# 2. Add tyche.
go get github.com/webdeveloperben/tyche
go install github.com/webdeveloperben/tyche/cmd/tyche@latest

# 3. Drop the Quick Start code into main.go (or any package).

# 4. Scaffold the config.
tyche init --module github.com/me/myapi/client --yes

# 5. Generate codecs (writes zz_server_routes_gen.go next to main.go).
tyche generate

# 6. Run.
go run .
# curl localhost:8080/api/users/u1

If you want to skip the reflection path entirely, build with a flag:

tyche build -o ./bin/api .        # generate, then go build .
tyche run .                       # generate, then go run .
tyche test ./...                  # generate, then go test ./...

These all run go run/go build/go test against a temporary copy of your project with codecs generated in place, so the real working tree is never touched by generated code.

Route input/output types may live anywhere, including a single-file package main — tyche keys main-package codecs to match Go's runtime reflection, so the small everything-in-main.go app works end to end.

The adapter model — routing is pluggable

An Adapter maps a (method, path) to a handler and dispatches requests. That is the only thing tyche delegates; binding, validation, serialization, middleware, error rendering, and OpenAPI are layered on top and are identical regardless of adapter.

type Adapter interface {
	Handle(method, path string, h http.Handler)
	ServeHTTP(w http.ResponseWriter, r *http.Request)
	SetFallback(notFound, methodNotAllowed http.Handler)
}

Paths arrive in tyche's template form (:name params, *name trailing wildcard); each adapter translates to its router's syntax.

Standard library (default)

server.NewServeMuxAdapter() uses net/http.ServeMux. Because tyche's binders read path parameters via (*http.Request).PathValue, the stdlib adapter is a zero-glue fit — ServeMux's native {name} matching populates exactly what the codecs read.

Bring your own router

Any router works in ~40 lines. The one router-specific detail is bridging its matched params onto req.PathValue so the binding layer stays agnostic. A chi adapter, in full:

type ChiAdapter struct{ mux chi.Router }

func NewChiAdapter() *ChiAdapter { return &ChiAdapter{mux: chi.NewRouter()} }

func (a *ChiAdapter) Handle(method, path string, h http.Handler) {
	bridged := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if rctx := chi.RouteContext(r.Context()); rctx != nil {
			for i, key := range rctx.URLParams.Keys {
				r.SetPathValue(key, rctx.URLParams.Values[i])
			}
		}
		h.ServeHTTP(w, r)
	})
	a.mux.Method(method, toChiPattern(path), bridged) // ":id" -> "{id}", "*" -> "*"
}

func (a *ChiAdapter) SetFallback(notFound, methodNotAllowed http.Handler) {
	a.mux.NotFound(notFound.ServeHTTP)
	a.mux.MethodNotAllowed(methodNotAllowed.ServeHTTP)
}

func (a *ChiAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.mux.ServeHTTP(w, r) }

tyche does not ship chi/gin/fiber adapters — that would bind the module to those routers' versions. The interface is the contract; a reference chi adapter lives in benchmarks/comparison/adapter_chi_test.go (a separate sub-module that pulls in chi only for the comparison benchmarks) and can be copied into your project as-is.

Typed routes & generated codecs

Register a handler as func(ctx, *In) (*Out, error). Input fields are bound from path, query, header, and cookie tags plus either a JSON body (Body field, body tag, or JSON-tagged fields) or a multipart form body (form, file, and files tags). Output fields map to a JSON body, response headers, and status.

Required vs optional. Parameters follow OpenAPI's defaults: query, header, and cookie fields are optional (absent values bind the zero value), while body fields are required so a client omitting a payload field gets a 400 instead of a silent zero value. Missing required fields render as problem+json validation errors.

Source Default Override
path required
query, header, cookie optional required:"true" or validate:"required"
JSON body, form, file, files required pointer type, ,omitempty tag option, required:"false", or validate:"omitempty"

By default this runs through a reflection binder — no codegen step, so iterating with go run is friction-free. tyche generate then inspects each server.Register(...) call and emits a codec (zz_server_routes_gen.go) that does the binding, validation, and serialization with hand-written byte-level code and no runtime reflection — conceptually the same trade sqlc makes for SQL. The generated codec is registered in an init() and picked up automatically; when present it replaces the reflection path for that route. Both paths produce byte-identical responses, so codegen is a pure performance optimization you reach for in production, not a prerequisite.

Multipart routes are supported by both the reflection binder and generated server codecs. Generated multipart codecs use the same form/file semantics as the runtime binder.

Successful responses are wrapped in a {"data": …} envelope; errors are RFC 9457 application/problem+json. JSON request bodies reject unsupported Content-Type values with 415, and JSON/SSE responses return 406 when the request Accept header does not allow the produced media type. Generated route metadata records the response content types emitted by generated codecs. The server-side Codec interface now owns the JSON request decode and success envelope path, with JSONCodec as the default implementation. Additional server-wide codecs can be registered on APIConfig.Codecs; JSON remains registered by default, and typed route OpenAPI content maps include configured codec media types for JSON request and success bodies.

Middleware — root, group, and route scopes

Middleware is func(next HandlerFunc) HandlerFunc, applied at three scopes that run outermost to innermost: root, group, then route.

// Root-level middleware, applied to every route (outermost first):
api.Use(plugins.Recoverer(), plugins.RealIP())

// Group-level tyche middleware:
v1 := api.Group("/v1", middleware.Auth(authSvc), middleware.AccessLog(logger))

// Route-level middleware, immediately before the handler:
server.Register(v1, chatOp, chatHandler,
	server.WithMiddleware(middleware.RequireScope("llm:chat")),
)

Helpers: server.MiddlewareFromFunc(fn), server.Chain(mw...), server.WithMiddleware(mw...), server.NamedMiddleware + UseNamed(...), and server.NewContextKey[T](name) for typed request-scoped values.

var authKey = server.NewContextKey[Claims]("auth")

func Auth(svc AuthService) server.Middleware {
	return server.MiddlewareFromFunc(func(w http.ResponseWriter, r *http.Request, next server.HandlerFunc) error {
		claims, err := svc.Verify(r)
		if err != nil {
			return server.NewHTTPError(http.StatusUnauthorized, "unauthorized")
		}
		return next(w, authKey.WithRequest(r, claims))
	})
}
Error handling

By default HTTPError and validation errors render as RFC 9457 application/problem+json, and unmatched routes / methods return problem+json 404 / 405 — on every adapter. Override any of these:

api.SetErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
	// map upstream provider errors, attach request IDs, log 5xx, then:
	server.DefaultErrorHandler(w, r, err)
})
api.SetNotFoundHandler(myNotFound)         // http.Handler
api.SetMethodNotAllowedHandler(my405)      // http.Handler
Streaming (Server-Sent Events)

RegisterStream registers a typed SSE endpoint: input is bound and validated like any typed route, the response is documented in OpenAPI as text/event-stream, and the handler streams type-safe events. (Streaming binds via reflection, since there is no generated codec for a streamed response.)

type StreamInput struct {
	Topic string `query:"topic" required:"true"`
}
type Token struct {
	Text string `json:"text"`
}

server.RegisterStream(v1, server.Operation{
	OperationID: "stream-tokens",
	Method:      http.MethodGet,
	Path:        "/chat/stream",
}, func(ctx context.Context, in *StreamInput, stream *server.Stream[Token]) error {
	for tok := range tokens(ctx, in.Topic) {
		if err := stream.Send(Token{Text: tok}); err != nil {
			return err // client disconnected
		}
	}
	return nil
})

For non-typed handlers, server.NewEventStream(w, r) returns a raw EventStream.

OpenAPI security
api.AddSecurityScheme("bearerAuth", server.BearerScheme("JWT"))
api.AddSecurityScheme("apiKey", server.APIKeyScheme("X-API-Key", "header"))

server.Register(v1, server.Operation{
	OperationID: "create-thing",
	Method:      http.MethodPost,
	Path:        "/things",
	Security:    []server.SecurityRequirement{{"bearerAuth": {}}},
}, handler)
Per-route body limits & mounting
// Override the API-wide MaxRequestBodyBytes for one route (0 = unlimited).
server.Register(v1, uploadOp, uploadHandler, server.WithMaxBodyBytes(100<<20))

// Mount any http.Handler (pprof, a metrics endpoint, another mux) at a prefix.
api.Mount("/debug/pprof", pprofMux)
Content negotiation

Typed routes use application/json by default. Register extra server codecs with APIConfig.Codecs; JSONCodec remains available automatically. OpenAPI advertises every configured codec for non-multipart request bodies and success responses, and runtime request/response selection uses Content-Type and Accept.

api := server.NewAPI(adapter, server.APIConfig{
	Codecs: []server.Codec{myVendorJSONCodec},
})

server.Register(api, op, handler,
	server.WithRequestContentTypes("application/json"),
	server.WithResponseContentTypes("application/vnd.example+json"),
)

When extra codecs are configured, older JSON-only generated route codecs are bypassed in favour of the negotiated reflection path. Regenerated route codecs receive the route codec set and keep the fast JSON path when JSON is selected.

Instrumentation

A dependency-free seam for tracing/metrics reporting method, route template, status, response bytes, and duration per request:

api.UseHTTP(plugins.InstrumentHTTP(plugins.ObserverFunc(func(i plugins.RequestInfo) {
	histogram.WithLabelValues(i.Method, i.Route, strconv.Itoa(i.Status)).Observe(i.Duration.Seconds())
})))

server.RoutePattern(r) returns the matched route template (e.g. /users/:id) as a low-cardinality metric label. OpenTelemetry is intentionally not a hard dependency; this is the bridge seam.

Testing — the servertest package

The servertest package builds requests against any http.Handler (an *API) and unwraps the standard DataResponse envelope:

client := servertest.New(t, api)
resp := client.POST("/users", User{Name: "Ada"}).AssertStatus(http.StatusCreated)
got := servertest.DecodeData[User](t, resp)

problem := servertest.DecodeProblem(t, client.GET("/secret").AssertStatus(401))
CLI reference
go install github.com/webdeveloperben/tyche/cmd/tyche@latest

tyche init                               # scaffold tyche.json next to go.mod
tyche config show                        # show the resolved config

tyche generate ./...                     # emit server codecs
tyche build -o ./bin/api ./cmd/api       # generate, then build
tyche run ./cmd/api                      # generate, then run
tyche test ./...                         # generate, then test

tyche client                             # regenerate the typed client from spec
# optional: keep distinct output/body/event types per operation
tyche client --type-naming operation-scoped

tyche version                            # print build identity
tyche completion bash > /etc/bash_completion.d/tyche

A tyche.json at the project root holds the inputs the CLI would otherwise take as flags. Discovery walks up from cwd to the first go.mod. Flags always override file values. Pass --config <path> to point at a non-default file or --quiet to suppress the "using config ..." line. Set TYCHE_CONFIG to point the CLI at a specific file via the environment.

Every command honours a global --format flag (human|json|quiet). Use --format=json to get machine-readable output suitable for | jq; use --format=quiet in CI and scripts where you only want the data line.

Embedding the CLI use-cases

The CLI is split so the use-case logic (scaffold, generate, regenerate client, worktree plumbing) lives in internal/app and is reachable without the CLI. If you embed tyche in your own tooling:

import "github.com/webdeveloperben/tyche/internal/app"

written, err := app.Scaffold(app.ScaffoldOptions{
    Root:   "/path/to/project",
    Module: "github.com/acme/api/client",
    Force:  false,
})

internal/app takes plain Go values; the CLI is a thin Kong adapter on top. The servergen, clientgen, and server packages never import internal/cli or any CLI framework.

Generated Go client

tyche client generates a self-contained, standard-library-only typed Go client from the OpenAPI spec your server emits — for Go code that consumes your API (other services, a CLI, a customer SDK). It bakes in tyche's conventions: the {"data": …} envelope and problem+json errors as a typed *APIError.

c := client.New("https://api.you.com", client.WithBearerToken(tok))

out, err := c.GetUser(ctx, &client.GetUserInput{ID: "u1"})
var apiErr *client.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { /* ... */ }

Because the client is its own module with its own tags, consumers go get …/client@vX.Y.Z and the contract is checked at compile time. SSE operations generate a streaming method returning a typed *Stream[Event] (scanner API: Next/Event/EventName/ID/Retry/Err/Close).

String and integer enums generate a named type with typed constants, allOf compositions of objects are merged into a single struct (keeping the component name), and a success response in a non-JSON media type returns []byte rather than being decoded or dropped.

Operations with multipart/form-data request bodies generate form, file, and files input fields. File inputs use the generated client.File type, which carries the part filename, content reader, and optional content type. Non-multipart request/response encoding goes through the generated Codec interface; client.WithCodec(...) can swap the default JSONCodec for a compatible JSON vendor media type or another implementation. The codec owns its own MediaType() and MatchesResponse(...), so a vendor JSON codec that wants to accept plain application/json responses (or vice versa) encodes that decision in the codec rather than in the runtime. By default, raw downloads and other non-envelope success responses send Accept from each operation's documented success media types, so a /report operation returns []byte against Accept: application/pdf instead of being decoded through the codec.

By default, structurally identical schemas share one generated Go type. Use --type-naming operation-scoped when distinct operations should keep distinct body/output/event types even if their schemas have the same shape.

Current limitations: oneOf/anyOf unions plus non-object allOf compositions are emitted as json.RawMessage.

Developing tyche

For day-to-day contributor workflow, test shapes, and the rules for breaking changes, see CONTRIBUTING.md. The short version:

  • Clone, mise install, lefthook install.
  • task tests runs the full suite through a generated worktree (the way CI does it). go test ./... runs the same tests directly without the worktree.
  • go build -o ./bin/tyche ./cmd/tyche then ./bin/tyche --help exercises the CLI locally without installing.
  • Lint with golangci-lint run ./...; check modernization with task modernize:check; fix with task modernize.
  • Breaking changes are allowed but must be called out in the PR description and added to the "Unreleased" section at the top of CHANGELOG.md.

For AI agents

If you're an agent helping someone adopt or contribute to tyche, here's the map:

  • What it is: a Go HTTP framework where handler signatures are func(ctx, *In) (*Out, error) and OpenAPI is derived from the types.
  • Entry points: server.NewAPI, server.Register, server.RegisterStream, server.NewServeMuxAdapter (server/server.go).
  • Codegen: cmd/tyche (CLI), internal/app (embeddable use-cases), servergen/ (server codec generation), clientgen/ (typed client generation).
  • Adapters: server.Adapter interface — stdlib ServeMuxAdapter ships by default; reference chi adapter in benchmarks/comparison/adapter_chi_test.go.
  • Conventions: {"data": …} success envelope, RFC 9457 problem+json errors, zz_server_routes_gen.go is generated and init()-registered.
  • Deep docs: CONTRIBUTING.md (dev loop, test shapes, breaking-change rules) and CHANGELOG.md (versioned history).
  • License: Apache 2.0 — see LICENSE.

License

Apache 2.0 — see LICENSE.

Directories

Path Synopsis
Package clientgen generates a self-contained, dependency-free typed Go client from a tyche-produced OpenAPI 3.x document.
Package clientgen generates a self-contained, dependency-free typed Go client from a tyche-produced OpenAPI 3.x document.
cmd
tyche command
Command tyche is the unified tyche CLI.
Command tyche is the unified tyche CLI.
internal
app
Package app holds the use-case orchestrators the tyche CLI runs.
Package app holds the use-case orchestrators the tyche CLI runs.
cli
Package cli is the user-facing surface of the tyche binary.
Package cli is the user-facing surface of the tyche binary.
config
Package config loads and validates the project's tyche.json file.
Package config loads and validates the project's tyche.json file.
output
Package output is the formatting layer for the tyche CLI.
Package output is the formatting layer for the tyche CLI.
ui
Package ui provides small terminal-interaction primitives the tyche CLI uses to detect TTY state and read single-line interactive input.
Package ui provides small terminal-interaction primitives the tyche CLI uses to detect TTY state and read single-line interactive input.
version
Package version exposes the build-time metadata of the tyche binary.
Package version exposes the build-time metadata of the tyche binary.
Package server provides typed HTTP handlers, OpenAPI generation, and a bring-your-own-router adapter model.
Package server provides typed HTTP handlers, OpenAPI generation, and a bring-your-own-router adapter model.
apidocs
Package apidocs mounts OpenAPI schema endpoints and pluggable API documentation UIs.
Package apidocs mounts OpenAPI schema endpoints and pluggable API documentation UIs.
openapi
Package openapi provides the schema and registry types used to build OpenAPI documents for typed server routes.
Package openapi provides the schema and registry types used to build OpenAPI documents for typed server routes.
plugins
Package plugins provides production-ready middleware for tyche APIs — recoverer, request ID, real IP, logging, timeout, rate limiting, CORS, security headers, gzip/brotli compression, and instrumentation — applied with api.Use (handler middleware) or api.UseHTTP (edge middleware).
Package plugins provides production-ready middleware for tyche APIs — recoverer, request ID, real IP, logging, timeout, rate limiting, CORS, security headers, gzip/brotli compression, and instrumentation — applied with api.Use (handler middleware) or api.UseHTTP (edge middleware).
servertest
Package servertest provides helpers for testing tyche routers and handlers with the standard library's httptest, removing the boilerplate of building requests and unwrapping the standard DataResponse envelope.
Package servertest provides helpers for testing tyche routers and handlers with the standard library's httptest, removing the boilerplate of building requests and unwrapping the standard DataResponse envelope.
validation
Package validation provides shared rule parsing and runtime validation helpers used by server request parsing, OpenAPI generation, and servergen.
Package validation provides shared rule parsing and runtime validation helpers used by server request parsing, OpenAPI generation, and servergen.
Package servergen generates zero-reflection transport codecs (request binding, validation, and response serialization) for typed server routes by analysing server.Register calls with go/packages.
Package servergen generates zero-reflection transport codecs (request binding, validation, and response serialization) for typed server routes by analysing server.Register calls with go/packages.

Jump to

Keyboard shortcuts

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