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.
// 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.// 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, "", " ")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.
go get github.com/DaniDeer/go-codex@latestRequires Go 1.25 or later.
| 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 |
- 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 Constraints —
email,uuid,url,date,date-timevalidated and reflected into schema automatically - Rich Codec Types — primitives,
Time/Date,Nullable[T],Bytes,SliceOf[T],StringMap[V], structs, tagged unions - OpenAPI Schema Generation —
components/schemasmap from codec-derived schemas, no manual YAML - Full OpenAPI 3.1 Document — complete REST API spec (paths, operations, params) from
route.Routedescriptors - AsyncAPI 2.6 Document — complete event-driven spec from channel descriptors; same schemas, no duplication
- REST API Builder — typed
Decode/Encodehelpers per route + OpenAPI spec generation, no HTTP library import - Event Channel Builder — typed
Decode/Encodehelpers per channel + AsyncAPI spec generation, no messaging library import - net/http Adapter — wire
RouteHandletonet/http.ServeMuxwith one call; 400/500 error handling included - Paho MQTT Adapter — wire
ChannelHandleto Paho MQTT subscribe callbacks; context-aware publish
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.
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.
// 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// 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}) // succeedsWhen 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.
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 hereNew is equivalent to calling Validate and then returning the original value. It is a thin wrapper — no new constraint logic.
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.
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.
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.
| 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"}Both combinators build a Codec[B] from an existing Codec[A] by supplying mapping functions. Choose based on how much validation you need.
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 stringovercodex.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 },
)func MapCodecValidated[A, B any](ca Codec[A], cb Codec[B], to func(A) (B, error), from func(B) (A, error)) Codec[B]- Both
toandfrommay return an error. - After mapping
A → B,cb.Validate(b)enforces allRefineconstraints defined oncb. - Validation also runs on the encode direction before
fromis called. - Schema comes from
cb(the domain type with its constraints). - Use when the mapping itself is fallible and the target type
Bcarries 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 maximumSpec: 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.
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. PathParamsnames exactly match{placeholder}segments in the path.- Path parameters are always
required: truein the output.
See examples/rest-api/ for a runnable demonstration.
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.
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.
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)
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.
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{...})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:
- Define your
.protofile — this is the source of truth for the wire format. - Run
protoc-gen-goto generate Go structs. - 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/openapirenders 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.
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.PortWhat you get for free:
- All field validation errors collected in one pass (not stop-at-first).
render/openapican 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.TOMLforformat.YAMLorformat.JSONwithout touching the codec.
What go-codex does not do: parse os.Args, generate --help output, or handle subcommands. Use cobra/flag for those.
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