go-codex

module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: May 21, 2026 License: MIT

README

GO Codex

CI

What is go-codex?

In standard Go, encoding, decoding, validation, and documentation are separate concerns that drift apart. Rename a field and you must update struct tags, the validator, and the schema docs independently — one missed update causes a silent bug or a stale spec.

go-codex is inspired by Haskell's autodocodec. A single Codec[T] value is the source of truth for encode, decode, validation, and schema — written once, never duplicated.

The Problem
// Three separate sources of truth — they drift.
type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func decodeUser(data []byte) (User, error) {
    var u User
    return u, json.Unmarshal(data, &u) // no validation
}

func validateUser(u User) error {
    if u.Name == "" {
        return errors.New("name: must not be empty")
    }
    if u.Age <= 0 {
        return errors.New("age: must be positive")
    }
    return nil
}

// Schema lives in a separate openapi.yaml — updated by hand.
The Solution
// One Codec[User] is encode + decode + validate + schema.
type User struct {
    Name string
    Age  int
}

var UserCodec = codex.Struct[User](
    codex.Field[User, string]{
        Name:     "name",
        Codec:    codex.String().Refine(validate.NonEmptyString),
        Get:      func(u User) string { return u.Name },
        Set:      func(u *User, v string) { u.Name = v },
        Required: true,
    },
    codex.Field[User, int]{
        Name:     "age",
        Codec:    codex.Int().Refine(validate.PositiveInt),
        Get:      func(u User) int { return u.Age },
        Set:      func(u *User, v int) { u.Age = v },
        Required: true,
    },
)

// Decode and validate in one step — error includes field path.
user, err := UserCodec.Decode(map[string]any{"name": "Alice", "age": 30})

// Encode back to the intermediate representation.
data, err := UserCodec.Encode(user)

// Schema derived automatically — no separate YAML needed.
schemaJSON, _ := json.MarshalIndent(UserCodec.Schema, "", "  ")
Shared Contract

go-codex codecs are plain Go values — they can live in a shared package and be imported by any number of services.

pkg/contract/
    user.go       ← UserCodec, CreateUserRequestCodec, ...
// server — decodes and validates incoming payloads
import "yourorg/pkg/contract"

user, err := contract.UserCodec.Decode(raw) // invalid input is rejected

// client — encodes outgoing payloads; generates OpenAPI spec from the same codec
spec, _ := openapi.MarshalYAML(map[string]schema.Schema{
    "User": contract.UserCodec.Schema,
})

A field rename in contract.User breaks compilation on both sides immediately — no stale YAML, no schema drift, no separate code-generation step. This is the key difference from protobuf or OpenAPI-first workflows: the Go source is the contract.

Installation & Usage

go get github.com/DaniDeer/go-codex@latest

Requires Go 1.25 or later.

Import paths
Package Import path
Core codecs github.com/DaniDeer/go-codex/codex
Format bridges (JSON, YAML, TOML) github.com/DaniDeer/go-codex/format
Built-in constraints github.com/DaniDeer/go-codex/validate
HTTP route descriptors github.com/DaniDeer/go-codex/route
REST API builder github.com/DaniDeer/go-codex/api/rest
Event channel builder github.com/DaniDeer/go-codex/api/events
net/http adapter github.com/DaniDeer/go-codex/adapters/nethttp
Paho MQTT adapter github.com/DaniDeer/go-codex/adapters/mqtt
OpenAPI 3.1 renderer github.com/DaniDeer/go-codex/render/openapi
AsyncAPI 2.6 renderer github.com/DaniDeer/go-codex/render/asyncapi
Schema model github.com/DaniDeer/go-codex/schema

Features

  • Multi-Format Support — one Codec[T] reads and writes JSON, YAML, and TOML unchanged
  • Encode, Decode, and Validation — constraints run on decode; encode is trusted; validate is explicit
  • Builtin Format Constraintsemail, uuid, url, date, date-time validated and reflected into schema automatically
  • Rich Codec Types — primitives, Time/Date, Nullable[T], Bytes, SliceOf[T], StringMap[V], structs, tagged unions
  • Structured Decode Errors — all failure types are concrete structs (ValidationErrors, ConstraintError, TypeMismatchError, ElementError, KeyError, UnknownVariantError, VariantError); use errors.As to inspect them, or pass them directly to log/slog
  • OpenAPI Schema Generationcomponents/schemas map from codec-derived schemas, no manual YAML
  • Full OpenAPI 3.1 Document — complete REST API spec (paths, operations, params) from route.Route descriptors
  • AsyncAPI 2.6 Document — complete event-driven spec from channel descriptors; same schemas, no duplication
  • REST API Builder — typed Decode/Encode helpers per route + OpenAPI spec generation, no HTTP library import
  • Event Channel Builder — typed Decode/Encode helpers per channel + AsyncAPI spec generation, no messaging library import
  • net/http Adapter — wire RouteHandle to net/http.ServeMux with one call; 400/500 error handling included
  • Paho MQTT Adapter — wire ChannelHandle to Paho MQTT subscribe callbacks; context-aware publish
Multi-Format Support

Codec[T] operates on an intermediate representation (map[string]any) that is format-agnostic. The format package bridges that intermediate to concrete wire formats — the same codec reads and writes JSON, YAML, and TOML unchanged.

jsonFmt := format.JSON(UserCodec)
yamlFmt := format.YAML(UserCodec)
tomlFmt := format.TOML(UserCodec)

// All three produce identical Go values; validation runs on all three.
user, err := jsonFmt.Unmarshal([]byte(`{"name":"Alice","age":30}`))
user, err  = yamlFmt.Unmarshal([]byte("name: Alice\nage: 30\n"))
user, err  = tomlFmt.Unmarshal([]byte("name = \"Alice\"\nage = 30\n"))

// Encode to any format.
jsonBytes, _ := jsonFmt.Marshal(user)
tomlBytes, _ := tomlFmt.Marshal(user)

Validation errors and field paths are identical regardless of which format is used.

Encode, Decode, and Validation
The trust boundary

go-codex draws a deliberate line between trusted and untrusted data:

Direction What runs Rationale
Decode type checks + all Refine constraints Input comes from outside — JSON on the wire, YAML from a file, a CLI flag. You cannot trust it. Every constraint runs.
Encode type conversion only The Go value was constructed by your own code. You already trust it. Running constraints on every encode would be redundant and surprising.

This mirrors the design of autodocodec: constraints are a guard on ingress, not a restriction on your own domain logic.

Decode — validates automatically
// Constraints run during Decode. Invalid input is rejected with field-path errors.
user, err := jsonFmt.Unmarshal([]byte(`{"name":"","age":-5}`))
// err: field name: constraint failed (non-empty): expected non-empty string
Encode — trusted, no constraints
// Encoding the value you constructed always succeeds (no constraints run).
// You are responsible for the correctness of values you build.
data, err := jsonFmt.Marshal(User{Name: "", Age: -5}) // succeeds
Validate — explicit bidirectional check

When you need to validate a Go value you constructed — before storing it, after building it programmatically, or to surface errors early — call Validate explicitly. It reuses the exact same Refine constraints, with no duplication:

// Codec.Validate — no format required.
if err := UserCodec.Validate(u); err != nil {
    return fmt.Errorf("constructed invalid user: %w", err)
}

// Format.Validate — same check, accessed through a Format binding.
if err := jsonFmt.Validate(u); err != nil {
    return err
}

Validate is always explicit. Marshal and Encode never silently validate.

New — smart constructor

Codec.New validates and returns the value in a single call. Use it as a smart constructor when you want to create a validated domain value from a Go value:

// Validate + return in one call.
email, err := emailCodec.New(Email("user@example.com"))
if err != nil {
    return err
}
// email is guaranteed valid here

New is equivalent to calling Validate and then returning the original value. It is a thin wrapper — no new constraint logic.

Must — panic on invalid (for constants and test data)

codex.Must is a generic panic-on-error helper, following the convention of template.Must and regexp.MustCompile. Use it for package-level validated constants or test data setup — places where an invalid value is a programming error, not a recoverable runtime condition:

// Package-level constant — panics at startup if "guest" is somehow invalid.
var guestUser = codex.Must(usernameCodec.New(Username("guest")))

// Test helper — panics immediately rather than hiding setup errors.
got := codex.Must(emailCodec.Decode("user@example.com"))

Must is generic and works with any (T, error) pair — New, Decode, MapCodecValidated, or your own functions.

Error Handling

All decode failures are structured types. Use errors.As to inspect them precisely, or pass them directly to log/slog — every type implements slog.LogValuer.

Error types
Type Returned by Key fields
ValidationErrors Struct decode []ValidationError; also implements Unwrap() []error
ValidationError each field in Struct decode Field string, Err error
ConstraintError Refine on any codec; also Int/Int64 for non-integral float Name string, Message string
TypeMismatchError any codec receiving wrong Go type Expected string, Got string
ElementError SliceOf decode Index int, Err error
KeyError StringMap decode Key string, Err error
UnknownVariantError TaggedUnion when tag value has no matching codec Tag string, Variant string
VariantError TaggedUnion when a known variant fails to decode/encode Tag string, Variant string, Err error
ErrMissingField required Field when key absent sentinel; use errors.Is
Inspecting errors with errors.As
var ve codex.ValidationErrors
if errors.As(err, &ve) {
    for _, fieldErr := range ve {
        var ce codex.ConstraintError
        if errors.As(fieldErr.Err, &ce) {
            // ce.Name   — constraint identifier, e.g. "email", "minLen(3)"
            // ce.Message — human-readable description of the failure
            fmt.Printf("field %q: constraint %q failed: %s\n",
                fieldErr.Field, ce.Name, ce.Message)
        }
        if errors.Is(fieldErr.Err, codex.ErrMissingField) {
            fmt.Printf("field %q is required but absent\n", fieldErr.Field)
        }
    }
}
Structured logging with log/slog

All error types implement slog.LogValuer. Pass them as slog attributes to get structured key-value output instead of a flat error string:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

var ve codex.ValidationErrors
if errors.As(err, &ve) {
    // Emits each field name and its error as separate slog attributes.
    logger.Error("request validation failed", slog.Any("validation_errors", ve))

    for _, fieldErr := range ve {
        var ce codex.ConstraintError
        if errors.As(fieldErr.Err, &ce) {
            // Emits field.field, field.error, constraint.constraint, constraint.message.
            logger.Warn("field constraint failed",
                slog.Any("field", fieldErr),
                slog.Any("constraint", ce),
            )
        }
    }
}

See examples/error-types/ for a runnable demo of every error type with errors.As and slog. See examples/decode-errors/ for struct validation errors and HTTP 400 response patterns.

Builtin Format Constraints

validate/ ships format constraints for common string types. Each constraint validates the value and annotates schema.Schema so the format appears in OpenAPI output automatically.

Constraint Validates OpenAPI format
validate.Email user@domain.tld email
validate.UUID RFC 4122 UUID (case-insensitive) uuid
validate.URL absolute http/https URL uri
validate.IPv4 dotted-decimal IPv4 ipv4
validate.IPv6 IPv6 address ipv6
validate.Date YYYY-MM-DD (ISO 8601) date
validate.DateTime RFC 3339 date-time date-time
validate.Slug lowercase-hyphen-slug pattern

Range / length constraints (with automatic schema annotation):

Constraint Applies to Validates
validate.MinLen(n) / MaxLen(n) string character count
validate.MinInt(n) / MaxInt(n) / RangeInt(a,b) int integer bounds
validate.MinFloat(n) / MaxFloat(n) / RangeFloat(a,b) float64 float bounds
validate.NonEmptyString string not empty
validate.PositiveInt / NegativeInt int sign
validate.OneOf(values...) string enum membership
validate.Pattern(re) string regexp match

Byte-size constraints (runtime-only, no schema annotation):

Constraint Applies to Validates
validate.MaxBytes(n) []byte decoded byte count ≤ n
validate.MinBytes(n) []byte decoded byte count ≥ n
var ContactCodec = codex.Struct[Contact](
    codex.Field[Contact, string]{
        Name:     "email",
        Codec:    codex.String().Refine(validate.Email).WithDescription("Primary email."),
        Get:      func(c Contact) string { return c.Email },
        Set:      func(c *Contact, v string) { c.Email = v },
        Required: true,
    },
    codex.Field[Contact, string]{
        Name:     "id",
        Codec:    codex.String().Refine(validate.UUID),
        Get:      func(c Contact) string { return c.ID },
        Set:      func(c *Contact, v string) { c.ID = v },
        Required: true,
    },
)

// Decode validates format automatically — no extra step.
contact, err := ContactCodec.Decode(map[string]any{
    "email": "not-an-email",   // → constraint failed (email): invalid email address: "not-an-email"
    "id":    "bad-uuid",       // → constraint failed (uuid): invalid UUID: "bad-uuid"
})

// OpenAPI schema includes format: email, format: uuid automatically.
yamlBytes, _ := openapi.MarshalYAML(map[string]schema.Schema{"Contact": ContactCodec.Schema})

See examples/formats/ for a runnable demo covering all constraints.

Custom Constraints

codex.Constraint[T] is the public API for defining your own validation rules. Pass any constraint directly to .Refine().

Inline (one-off):

var AvatarCodec = codex.Bytes().Refine(codex.Constraint[[]byte]{
    Name:    "maxBytes(65536)",
    Check:   func(v []byte) bool { return len(v) <= 65536 },
    Message: func(v []byte) string {
        return fmt.Sprintf("expected at most 65536 bytes, got %d", len(v))
    },
})

Reusable (like validate/*):

func MaxBytes(n int) codex.Constraint[[]byte] {
    return codex.Constraint[[]byte]{
        Name:  fmt.Sprintf("maxBytes(%d)", n),
        Check: func(v []byte) bool { return len(v) <= n },
        Message: func(v []byte) string {
            return fmt.Sprintf("expected at most %d bytes, got %d", n, len(v))
        },
    }
}

var AvatarCodec = codex.Bytes().Refine(MaxBytes(65536))

With schema annotation — set Constraint.Schema to propagate constraint metadata into the generated OpenAPI/AsyncAPI schema:

func MaxLen(n int) codex.Constraint[string] {
    return codex.Constraint[string]{
        Name:  fmt.Sprintf("maxLen(%d)", n),
        Check: func(v string) bool { return len(v) <= n },
        Message: func(v string) string {
            return fmt.Sprintf("expected at most %d characters, got %d", n, len(v))
        },
        Schema: func(s schema.Schema) schema.Schema {
            s.MaxLength = &n    // ← reflected into OpenAPI output automatically
            return s
        },
    }
}

The validate/ package ships ready-made constraints using this exact pattern (MinLen, MaxLen, RangeInt, Email, etc.). validate.MaxBytes and validate.MinBytes are built-in for []byte byte-count limits.

Available Codec Types
Constructor Go type JSON wire Schema
codex.Int() int number {type:integer}
codex.Int64() int64 number {type:integer}
codex.Float64() float64 number {type:number}
codex.String() string string {type:string}
codex.Bool() bool boolean {type:boolean}
codex.Bytes() []byte base64 string {type:string,format:byte}
codex.Time() time.Time RFC 3339 string {type:string,format:date-time}
codex.Date() time.Time YYYY-MM-DD string {type:string,format:date}
codex.Nullable(inner) *T value or null inner schema + nullable:true
codex.SliceOf(elem) []T array {type:array,items:{...}}
codex.StringMap(value) map[string]V object {type:object,additionalProperties:{...}}
codex.Struct[T](fields...) any struct object {type:object,properties:{...}}
codex.TaggedUnion[T](tag, variants...) any interface object {oneOf:[...],discriminator:{...}}
// Nullable pointer field
var noteCodec = codex.Nullable(codex.String())  // Codec[*string]
note, _ := noteCodec.Decode(nil)                // → (*string)(nil)
s := "hello"
enc, _ := noteCodec.Encode(&s)                  // → "hello"
enc, _ = noteCodec.Encode(nil)                  // → nil (JSON null)

// Time and Date
var createdAtCodec = codex.Time()               // Codec[time.Time]
enc, _ := createdAtCodec.Encode(time.Now())     // → "2024-06-15T12:00:00Z"

// StringMap
var tagsCodec = codex.StringMap(codex.String()) // Codec[map[string]string]
enc, _ := tagsCodec.Encode(map[string]string{"env":"prod"})
// → map[string]any{"env":"prod"}
Codec Transformations: MapCodecSafe and MapCodecValidated

Both combinators build a Codec[B] from an existing Codec[A] by supplying mapping functions. Choose based on how much validation you need.

MapCodecSafe — type mapping, infallible decode direction
func MapCodecSafe[A, B any](c Codec[A], to func(A) B, from func(B) (A, error)) Codec[B]
  • to (decode direction) must always succeed — it is a total function.
  • from (encode direction) may return an error.
  • Schema is inherited from Codec[A] (the wire codec).
  • Use for newtype wrappers: type Email string over codex.String().
type Email string

var EmailCodec = codex.MapCodecSafe(
    codex.String(),
    func(s string) Email { return Email(s) },
    func(e Email) (string, error) { return string(e), nil },
)
MapCodecValidated — fallible mapping with post-decode validation
func MapCodecValidated[A, B any](ca Codec[A], cb Codec[B], to func(A) (B, error), from func(B) (A, error)) Codec[B]
  • Both to and from may return an error.
  • After mapping A → B, cb.Validate(b) enforces all Refine constraints defined on cb.
  • Validation also runs on the encode direction before from is called.
  • Schema comes from cb (the domain type with its constraints).
  • Use when the mapping itself is fallible and the target type B carries its own validation rules.
type Celsius float64

var celsiusBaseCodec = codex.MapCodecSafe(
    codex.Float64().
        Refine(validate.MinFloat(-273.15)).
        Refine(validate.MaxFloat(1_000_000)),
    func(f float64) Celsius { return Celsius(f) },
    func(c Celsius) (float64, error) { return float64(c), nil },
)

var celsiusCodec = codex.MapCodecValidated(
    codex.Float64(),    // ca: wire codec
    celsiusBaseCodec,   // cb: domain codec with range constraints
    func(f float64) (Celsius, error) {
        if f != f { // NaN
            return 0, errors.New("NaN is not a valid temperature")
        }
        return Celsius(f), nil
    },
    func(c Celsius) (float64, error) { return float64(c), nil },
)

temp, err := celsiusCodec.Decode(float64(36.6)) // → Celsius(36.6), nil
_, err = celsiusCodec.Decode(float64(-300))     // → error: below absolute zero
_, err = celsiusCodec.Encode(Celsius(2e6))      // → error: exceeds maximum
OpenAPI Schema Generation

Spec: openapis.org - OpenAPI 3.2.0

Codec[T] carries a schema.Schema that describes the type: field names, types, constraints, descriptions, and examples. The render/openapi package converts that schema into an OpenAPI 3.x components/schemas map — no manual YAML authoring, no drift.

import (
    "github.com/DaniDeer/go-codex/render/openapi"
    "github.com/DaniDeer/go-codex/validate"
)

var UserCodec = codex.Struct[User](
    codex.Field[User, string]{
        Name: "name",
        Codec: codex.String().
            Refine(validate.NonEmptyString).
            Refine(validate.MaxLen(100)).
            WithTitle("Full Name").
            WithDescription("The user's full display name."),
        Get:      func(u User) string { return u.Name },
        Set:      func(u *User, v string) { u.Name = v },
        Required: true,
    },
    codex.Field[User, int]{
        Name: "age",
        Codec: codex.Int().
            Refine(validate.RangeInt(0, 150)).
            WithDescription("Age in years."),
        Get:      func(u User) int { return u.Age },
        Set:      func(u *User, v int) { u.Age = v },
        Required: true,
    },
)

// Render components/schemas as YAML — ready to paste into openapi.yaml.
yamlBytes, err := openapi.MarshalYAML(map[string]schema.Schema{
    "User": UserCodec.Schema,
})

Output (trimmed):

User:
  type: object
  properties:
    name:
      type: string
      title: Full Name
      description: The user's full display name.
      minLength: 1
      maxLength: 100
    age:
      type: integer
      description: Age in years.
      minimum: 0
      maximum: 150
  required: [name, age]

The same UserCodec encodes, decodes, validates, and documents — written once.

Constraint schema reflection is opt-in: validate.* constraints (e.g. MinLen, RangeInt, OneOf, Pattern) automatically annotate the schema. Custom constraints can do the same by setting Constraint.Schema.

See examples/openapi/ for a runnable demonstration.

Full OpenAPI 3.1 Document

render/openapi can emit a complete OpenAPI 3.1 document — not just components/schemas — using the DocumentBuilder. Define HTTP routes with route.Route descriptors that reference codec schemas; the builder assembles paths, operations, parameters, request bodies, responses, and components/schemas in one step.

Schemas named via Body.SchemaName or Response.SchemaName are automatically registered in components/schemas and referenced with $ref. Unnamed schemas are inlined.

import (
    "github.com/DaniDeer/go-codex/render/openapi"
    "github.com/DaniDeer/go-codex/route"
)

doc, err := openapi.NewDocumentBuilder(openapi.Info{
    Title:   "User API",
    Version: "1.0.0",
}).
    AddServer(openapi.Server{URL: "https://api.example.com/v1"}).
    AddRoute(route.Route{
        Method:      "POST",
        Path:        "/users",
        OperationID: "createUser",
        Summary:     "Create a user",
        RequestBody: &route.Body{
            Required:   true,
            Schema:     CreateUserRequestCodec.Schema,
            SchemaName: "CreateUserRequest", // → $ref + registered in components
        },
        Responses: []route.Response{
            {Status: "201", Description: "Created", Schema: &UserCodec.Schema, SchemaName: "User"},
            {Status: "400", Description: "Validation error."},
        },
    }).
    AddRoute(route.Route{
        Method: "GET",
        Path:   "/users/{id}",
        PathParams: []route.Param{
            {Name: "id", Required: true, Schema: schema.Schema{Type: "string", Format: "uuid"}},
        },
        Responses: []route.Response{
            {Status: "200", Description: "OK", Schema: &UserCodec.Schema, SchemaName: "User"},
            {Status: "204", Description: "No Content"}, // no body — content omitted
        },
    }).
    Build()

yamlBytes, err := doc.MarshalYAML()

Build() validates:

  • No duplicate (method, path) pairs.
  • PathParams names exactly match {placeholder} segments in the path.
  • Path parameters are always required: true in the output.

See examples/rest-api/ for a runnable demonstration.

AsyncAPI 2.6 Document

Spec: asyncapi.com - specification 3.1.0

render/asyncapi produces a full AsyncAPI 2.6 document from channel descriptors. The same schema.Schema that drives OpenAPI output also describes AsyncAPI message payloads — no duplication.

import "github.com/DaniDeer/go-codex/render/asyncapi"

doc, err := asyncapi.NewDocumentBuilder(asyncapi.Info{
    Title:   "User Events",
    Version: "1.0.0",
}).
    AddServer("production", asyncapi.Server{
        URL:      "amqp://broker.example.com",
        Protocol: "amqp",
    }).
    AddChannel("user/created", asyncapi.ChannelItem{
        Subscribe: &asyncapi.Operation{
            Summary: "User created",
            Message: asyncapi.Message{
                Schema:     UserCreatedEventCodec.Schema,
                SchemaName: "UserCreatedEvent", // → $ref + registered in components
            },
        },
    }).
    Build()

yamlBytes, err := doc.MarshalYAML()

Output (trimmed):

asyncapi: 2.6.0
info:
  title: User Events
  version: 1.0.0
channels:
  user/created:
    subscribe:
      summary: User created
      message:
        payload:
          $ref: "#/components/schemas/UserCreatedEvent"
components:
  schemas:
    UserCreatedEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, minLength: 1 }

See examples/event-driven/ for a runnable demonstration.

REST API Builder

api/rest is a transport-agnostic REST API builder. Register routes with codec-backed request and response types; the builder returns a RouteHandle with typed Decode and Encode helpers. Pass those helpers to any HTTP framework — this package imports no HTTP library.

The same builder generates a complete OpenAPI 3.1 spec from all registered routes.

import "github.com/DaniDeer/go-codex/api/rest"

b := rest.NewBuilder(rest.Info{Title: "User API", Version: "1.0.0"})
b.AddServer(rest.Server{URL: "https://api.example.com/v1"})

// AddRoute returns a RouteHandle — typed Decode/Encode helpers, no net/http import.
createUser := rest.AddRoute[CreateUserRequest, User](b, "POST", "/users",
    createUserCodec, userCodec,
    rest.RouteConfig{
        OperationID:    "createUser",
        Summary:        "Create a user",
        ReqSchemaName:  "CreateUserRequest",
        RespSchemaName: "User",
        Responses: []rest.ResponseMeta{
            {Status: "400", Description: "Validation error."},
        },
    })

// In your HTTP handler — works with net/http, Gin, Chi, Echo, anything:
req, err := createUser.Decode(body)   // JSON → CreateUserRequest, validates
user, err := myService.Create(req)
out, err  := createUser.Encode(user)  // User → JSON

// Route descriptor for your framework's router:
fmt.Println(createUser.Descriptor.Method, createUser.Descriptor.Path) // POST /users

// OpenAPI 3.1 spec from all registered routes:
doc, err := b.OpenAPISpec()
yamlBytes, _ := doc.MarshalYAML()

Future: framework-specific adapters (adapters/gin, adapters/chi, etc.) will wrap RouteHandle for zero-boilerplate integration. The api/rest core stays dependency-free.

See examples/api-rest/ for a runnable demonstration, and examples/adapters-nethttp/ for the net/http adapter.

net/http Adapter

adapters/nethttp wires a RouteHandle to net/http in one line. No boilerplate for body reading, JSON encoding, or error response formatting.

import nethttp "github.com/DaniDeer/go-codex/adapters/nethttp"

mux := http.NewServeMux()

// Register uses the Go 1.22+ "METHOD /path" ServeMux pattern automatically.
nethttp.Register(mux, createUser, func(ctx context.Context, req CreateUserReq) (User, error) {
    return svc.CreateUser(ctx, req)
})

http.ListenAndServe(":8080", mux)
  • POST/PUT/PATCH: body read → handle.Decode (validates) → handler → handle.Encode → write
  • GET/HEAD/DELETE: handler called with zero value of Req; path/query extraction via middleware or context
  • Errors: {"error":"..."} JSON — 400 for decode/validation, 500 for handler/encode failures
  • Response status: taken from the route descriptor's primary response (e.g. 201 for POST)
Event Channel Builder

api/events is a transport-agnostic event channel builder. Register channels with codec-backed payload types; the builder returns a ChannelHandle with typed Decode and Encode helpers. Pass those helpers to any message broker — this package imports no messaging library.

The same builder generates a complete AsyncAPI 2.6 spec from all registered channels.

import "github.com/DaniDeer/go-codex/api/events"

b := events.NewBuilder(events.Info{Title: "User Events", Version: "1.0.0"})
b.AddServer("production", events.Server{URL: "amqp://broker.example.com", Protocol: "amqp"})

// AddChannel returns a ChannelHandle — typed Decode/Encode helpers, no broker import.
userCreated := events.AddChannel[UserCreatedEvent](b, "user/created", userCreatedCodec,
    events.ChannelConfig{
        Subscribe: &events.OperationConfig{
            Summary:    "A user was created",
            SchemaName: "UserCreatedEvent",
        },
    })

// In your broker callback — works with Paho MQTT, AMQP, Kafka, NATS, anything:
event, err := userCreated.Decode(msg.Payload()) // JSON → UserCreatedEvent, validates
handleUserCreated(event)

// Publish:
payload, _ := userCreated.Encode(UserCreatedEvent{...})
client.Publish(userCreated.Topic, payload)

// AsyncAPI 2.6 spec from all registered channels:
doc, err := b.AsyncAPISpec()
yamlBytes, _ := doc.MarshalYAML()

Both subscribe and publish directions can be registered on the same channel:

events.AddChannel[UserEvent](b, "user/events", codec, events.ChannelConfig{
    Subscribe: &events.OperationConfig{Summary: "Receive user events"},
    Publish:   &events.OperationConfig{Summary: "Send user events"},
})

Future: broker-specific adapters (adapters/amqp, adapters/kafka, etc.) will wrap ChannelHandle for zero-boilerplate integration.

See examples/api-events/ for a runnable demonstration, and examples/adapters-mqtt/ for the Paho MQTT adapter.

Paho MQTT Adapter

adapters/mqtt wires a ChannelHandle to Paho MQTT. SubscribeHandler returns a mqtt.MessageHandler ready to pass to client.Subscribe. Publish encodes the value and publishes it, waiting for broker acknowledgement with context-aware cancellation.

import (
    mqtt    "github.com/eclipse/paho.mqtt.golang"
    amqtt   "github.com/DaniDeer/go-codex/adapters/mqtt"
)

// Subscribe: decode + validate incoming messages automatically.
client.Subscribe(userCreated.Topic, 1,
    amqtt.SubscribeHandler(ctx, userCreated,
        func(ctx context.Context, e UserCreatedEvent) error {
            return svc.HandleUserCreated(ctx, e)
        },
        func(err error) { log.Println("event error:", err) },
    ),
)

// Publish: encode outgoing message and wait for broker ack.
err := amqtt.Publish(ctx, client, notifChannel, 1, false, NotificationCommand{...})

Special Topics

Protobuf Integration

go-codex and Protobuf solve different problems. In a proto-first workflow the two complement each other cleanly.

Ownership model:

Concern Owner
Wire format, field numbers, binary encoding .proto + protoc-gen-go
Validation rules, richer documentation, format-agnostic decode Codec[T]

Workflow:

  1. Define your .proto file — this is the source of truth for the wire format.
  2. Run protoc-gen-go to generate Go structs.
  3. Write a Codec[T] on top of the generated struct to add what proto cannot express: validation constraints, field descriptions, examples, and format-agnostic (JSON/YAML/TOML) decode.
// Generated by protoc-gen-go — do not edit.
type CreateUserRequest struct {
    Name  string
    Email string
    Age   int32
}

// Defined by you — the codec adds validation + documentation.
var CreateUserRequestCodec = codex.Struct[CreateUserRequest](
    codex.Field[CreateUserRequest, string]{
        Name:     "name",
        Codec:    codex.String().Refine(validate.NonEmptyString).WithDescription("Display name."),
        Get:      func(r CreateUserRequest) string { return r.Name },
        Set:      func(r *CreateUserRequest, v string) { r.Name = v },
        Required: true,
    },
    // ...
)

What this gives you:

  • gRPC handles binary transport; the codec handles REST/JSON/YAML config validation.
  • render/openapi renders the codec's schema as OpenAPI documentation — no separate YAML file.
  • Validation rules (Refine) live in Go, next to the type, not scattered across proto options.

What this is not: go-codex does not generate .proto files from codecs, and does not read .proto files. The proto file is the wire-format source of truth; the codec is the validation-and-documentation source of truth. These concerns are intentionally separate.

CLI Tools

Go is a popular language for CLI tools. go-codex is well suited for config file decoding (YAML, TOML, JSON): define a codec once and get type-safe parsing, structured validation errors, and auto-generated JSON Schema documentation for free.

For command-line flag and argument parsing (the --flag value part), use cobra, pflag, or the standard flag package — they handle --help, shell completion, and usage text that codecs are not designed for.

Where go-codex fits in a CLI: read the config file → decode with the codec → get typed struct with all validation errors collected upfront.

// Config is the application configuration struct.
type Config struct {
    Port    int
    LogLevel string
}

var configCodec = codex.Struct[Config](
    codex.RequiredField[Config, int]("port", codex.Int().Refine(validate.RangeInt(1, 65535)),
        func(c Config) int { return c.Port },
        func(c *Config, v int) { c.Port = v },
    ),
    codex.OptionalField[Config, string]("log_level",
        codex.String().Refine(validate.OneOf("debug", "info", "warn", "error")),
        func(c Config) string { return c.LogLevel },
        func(c *Config, v string) { c.LogLevel = v },
    ),
)

// In main() or cobra's PersistentPreRunE:
data, _ := os.ReadFile("config.toml")
cfg, err := format.TOML(configCodec).Unmarshal(data)
if err != nil {
    // err is a codex.ValidationErrors — all field errors collected at once.
    log.Fatal(err)
}
// cfg is fully validated and typed.
_ = cfg.Port

What you get for free:

  • All field validation errors collected in one pass (not stop-at-first).
  • render/openapi can render the codec's schema as JSON Schema for documentation or editor autocomplete.
  • The same codec works with JSON, YAML, and TOML config files — swap format.TOML for format.YAML or format.JSON without touching the codec.

What go-codex does not do: parse os.Args, generate --help output, or handle subcommands. Use cobra/flag for those.

Project Structure

go-codex/
├── go.mod
├── README.md

├── codex/                  # ⭐ PUBLIC API: codecs, primitives, struct, union, slice
│   ├── codec.go            # Codec[T], WithDescription, WithTitle, Validate, New
│   ├── errors.go           # ValidationError, ValidationErrors
│   ├── map.go              # MapCodecSafe, MapCodecValidated, Downcast
│   ├── must.go             # Must[T] — generic panic-on-error helper
│   ├── nullable.go         # Nullable[T]
│   ├── object.go           # Field[T,F], RequiredField, OptionalField, Struct[T]
│   ├── primitives.go       # Int, Int64, Float64, String, Bool, Bytes
│   ├── refine.go           # Constraint[T], Refine (Constraint.Schema for schema reflection)
│   ├── slice.go            # SliceOf[T]
│   ├── stringmap.go        # StringMap[V]
│   ├── time.go             # Time(), Date()
│   └── union.go            # TaggedUnion[T]
│
├── format/                 # format bridges: JSON, YAML, TOML
│   └── format.go           # Format[T], JSON(), YAML(), TOML(), New()
│
├── route/                  # HTTP route descriptors (no renderer logic)
│   └── route.go            # Route, Param, Body, Response
│
├── api/                    # API builders (no HTTP or messaging library imports)
│   ├── rest/               # REST API builder: typed Decode/Encode + OpenAPI spec
│   │   └── builder.go      # Builder, AddRoute[Req,Resp], AddServer, AddSchema, RouteHandle
│   └── events/             # Event channel builder: typed Decode/Encode + AsyncAPI spec
│       └── builder.go      # Builder, AddChannel[T], AddServer, AddSchema, ChannelHandle
│
├── adapters/               # transport-specific adapters (wrap api/rest or api/events)
│   ├── nethttp/            # net/http adapter for api/rest RouteHandles
│   │   └── adapter.go      # Handler, Register, HandlerWithOptions, RequestFromContext
│   └── mqtt/               # Paho MQTT adapter for api/events ChannelHandles
│       └── adapter.go      # Subscribe, Publish, SubscribeError, ErrorKind
│
├── render/                 # spec renderers (import schema only, or schema + route)
│   ├── internal/
│   │   └── schemarender/   # shared schema-to-map renderer (used by openapi + asyncapi)
│   │       └── schemarender.go  # SchemaObject
│   ├── openapi/            # OpenAPI 3.1 renderer
│   │   ├── openapi.go      # SchemaObject, ComponentsSchemas, MarshalJSON, MarshalYAML
│   │   └── document.go     # DocumentBuilder, Document, Info, Server — full 3.1 spec
│   └── asyncapi/           # AsyncAPI 2.6 renderer
│       ├── asyncapi.go     # delegates schema rendering to render/internal/schemarender
│       └── document.go     # DocumentBuilder, Document, ChannelItem, Operation, Message
│
├── schema/                 # schema model (pure data, zero dependencies)
│   └── schema.go           # Schema, Property, DiscriminatorSchema
│
├── validate/               # reusable constraints (reflect into schema automatically)
│   ├── bytes.go            # MaxBytes(n), MinBytes(n)
│   ├── float.go            # PositiveFloat, NegativeFloat, MinFloat, MaxFloat, RangeFloat
│   ├── format.go           # Email, UUID, URL, IPv4, IPv6, Date, DateTime, Slug
│   ├── int.go              # PositiveInt, NegativeInt, MinInt, MaxInt, RangeInt
│   └── string.go           # NonEmptyString, MinLen, MaxLen, Pattern, OneOf
│
└── examples/               # usage demonstrations — not importable
    ├── adapters-mqtt/      # Paho MQTT adapter: wiring api/events to Paho client
    ├── adapters-nethttp/   # net/http adapter: wiring api/rest to ServeMux
    ├── api-events/         # Event channel builder: typed helpers + AsyncAPI spec
    ├── api-rest/           # REST API builder: typed helpers + OpenAPI spec
    ├── decode-errors/      # multi-field ValidationErrors + errors.As demo
    ├── event-driven/       # full AsyncAPI 2.6 document from channel descriptors
    ├── formats/            # builtin format constraints demo (Email, UUID, URL, ...)
    ├── html-sanitize/      # sanitizing untrusted HTML input with a codec
    ├── multiformat/        # JSON / YAML / TOML with one codec
    ├── openapi/            # OpenAPI components/schemas generation from a Codec
    ├── order/              # nested structs, SliceOf, Time, Nullable, StringMap demo
    ├── rest-api/           # full OpenAPI 3.1 document from route descriptors
    ├── shape/              # tagged union + Downcast demo
    ├── templ-mapper/       # mapping codec-validated data to templ components
    ├── validate/           # explicit Validate before marshal
    ├── mapvalidated/       # MapCodecValidated: fallible mapping + domain validation
    └── construction/       # New + Must: construction-time validation demo

Directories

Path Synopsis
adapters
mqtt
Package mqtt adapts api/events channel handles to [Paho MQTT] callbacks.
Package mqtt adapts api/events channel handles to [Paho MQTT] callbacks.
nethttp
Package nethttp adapts api/rest route handles to net/http handlers.
Package nethttp adapts api/rest route handles to net/http handlers.
api
events
Package events provides a transport-agnostic event channel builder for go-codex.
Package events provides a transport-agnostic event channel builder for go-codex.
rest
Package rest provides a transport-agnostic REST API builder for go-codex.
Package rest provides a transport-agnostic REST API builder for go-codex.
examples
adapters-mqtt command
Package adapters-mqtt demonstrates wiring the api/events builder to Paho MQTT using the adapters/mqtt adapter.
Package adapters-mqtt demonstrates wiring the api/events builder to Paho MQTT using the adapters/mqtt adapter.
adapters-nethttp command
Package adapters-nethttp demonstrates wiring the api/rest builder to a standard net/http server using the adapters/nethttp adapter.
Package adapters-nethttp demonstrates wiring the api/rest builder to a standard net/http server using the adapters/nethttp adapter.
api-events command
Package api-events demonstrates the api/events builder: define channels with codec-backed payload types, get typed Decode/Encode helpers, and generate a full AsyncAPI 2.6 spec — all without importing any messaging library.
Package api-events demonstrates the api/events builder: define channels with codec-backed payload types, get typed Decode/Encode helpers, and generate a full AsyncAPI 2.6 spec — all without importing any messaging library.
api-rest command
Package api-rest demonstrates the api/rest builder: define routes with codec-backed types, get typed Decode/Encode helpers, and generate a full OpenAPI 3.1 spec — all without importing net/http or any HTTP framework.
Package api-rest demonstrates the api/rest builder: define routes with codec-backed types, get typed Decode/Encode helpers, and generate a full OpenAPI 3.1 spec — all without importing net/http or any HTTP framework.
construction command
decode-errors command
Package decode-errors demonstrates multi-field validation errors in go-codex.
Package decode-errors demonstrates multi-field validation errors in go-codex.
error-types command
Package error-types demonstrates every structured error type in go-codex.
Package error-types demonstrates every structured error type in go-codex.
event-driven command
Package event-driven demonstrates generating a full AsyncAPI 2.6 document from channel descriptors and Codec-derived schemas using the render/asyncapi package.
Package event-driven demonstrates generating a full AsyncAPI 2.6 document from channel descriptors and Codec-derived schemas using the render/asyncapi package.
formats command
Package formats demonstrates the builtin format constraints in validate/.
Package formats demonstrates the builtin format constraints in validate/.
html-sanitize command
Package main demonstrates where go-codex shines in a comment moderation use case: a single codec definition simultaneously escapes HTML, enforces length limits, and documents the schema — all derived from one value.
Package main demonstrates where go-codex shines in a comment moderation use case: a single codec definition simultaneously escapes HTML, enforces length limits, and documents the schema — all derived from one value.
mapvalidated command
multiformat command
openapi command
Package openapi demonstrates generating an OpenAPI components/schemas section from Codec definitions using the render/openapi package.
Package openapi demonstrates generating an OpenAPI components/schemas section from Codec definitions using the render/openapi package.
order command
rest-api command
Package rest-api demonstrates generating a full OpenAPI 3.1 document from route descriptors and Codec-derived schemas using the render/openapi package.
Package rest-api demonstrates generating a full OpenAPI 3.1 document from route descriptors and Codec-derived schemas using the render/openapi package.
shape command
templ-mapper command
Package main demonstrates how go-codex fits into a templ-based rendering pipeline.
Package main demonstrates how go-codex fits into a templ-based rendering pipeline.
validate command
Package main shows how to use Codec.Validate and Format.Validate for explicit bidirectional validation.
Package main shows how to use Codec.Validate and Format.Validate for explicit bidirectional validation.
Package format bridges Codec[T] to concrete serialization formats (JSON, YAML, TOML).
Package format bridges Codec[T] to concrete serialization formats (JSON, YAML, TOML).
render
asyncapi
Package asyncapi renders schema.Schema values as an AsyncAPI 2.6 document.
Package asyncapi renders schema.Schema values as an AsyncAPI 2.6 document.
internal/schemarender
Package schemarender converts schema.Schema values to map[string]any objects suitable for marshalling into OpenAPI or AsyncAPI documents.
Package schemarender converts schema.Schema values to map[string]any objects suitable for marshalling into OpenAPI or AsyncAPI documents.
openapi
document.go provides DocumentBuilder for assembling a full OpenAPI 3.1 document from route descriptors and named schemas.
document.go provides DocumentBuilder for assembling a full OpenAPI 3.1 document from route descriptors and named schemas.
Package route describes HTTP operations for use with API spec renderers.
Package route describes HTTP operations for use with API spec renderers.

Jump to

Keyboard shortcuts

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