kori

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 1, 2026 License: MIT Imports: 10 Imported by: 0

README

Kori

A Go toolkit for Chi — inspired by Hono, made idiomatic in Go.

Kori adds three things to vanilla Chi:

  1. Error return — handlers return error instead of writing 500 by hand
  2. Binding helpersBindQuery, BindJSON, BindPath, BindHeader
  3. Response helpersJSON, Text, NoContent

w http.ResponseWriter and r *http.Request stay explicit. Chi is never hidden.


Installation

go get github.com/douglasmai4/kori

Go 1.22+.


Quick start

package main

import (
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/douglasmai4/kori"
)

func main() {
    r := chi.NewRouter()

    kori.GET(r, "/health", func(w http.ResponseWriter, r *http.Request) error {
        return kori.Text(w, http.StatusOK, "ok")
    })

    kori.GET(r, "/users/{id}", getUser)
    kori.POST(r, "/users", createUser)

    http.ListenAndServe(":8080", r)
}

func getUser(w http.ResponseWriter, r *http.Request) error {
    id := chi.URLParam(r, "id") // Chi directly, no abstraction
    user, err := db.Get(id)
    if err != nil {
        return kori.NotFound("user not found")
    }
    return kori.JSON(w, http.StatusOK, user)
}

type CreateBody struct {
    Name  string `json:"name"  validate:"required,min=2"`
    Email string `json:"email" validate:"required,email"`
}

func createUser(w http.ResponseWriter, r *http.Request) error {
    var body CreateBody
    if err := kori.BindJSON(r, &body); err != nil {
        return err // 400 or 422 automatically
    }
    user, err := db.Create(body.Name, body.Email)
    if err != nil {
        return kori.Conflict("email already in use")
    }
    return kori.JSON(w, http.StatusCreated, user)
}

Handler

type Handler func(w http.ResponseWriter, r *http.Request) error

It's a normal Go function with one difference: it can return error.

  • nil → nothing happens (you already wrote the response)
  • *kori.HTTPError → JSON response with the error's status code
  • any other error → 500 Internal Server Error

http.ResponseWriter and *http.Request are always yours. You can call any stdlib function, any third-party helper, any Chi middleware — everything works as usual.


Binding

kori.BindQuery(r, &dst)

Populates a struct from query params using the query tag.

type Params struct {
    Page   int      `query:"page"  validate:"min=1"`
    Limit  int      `query:"limit" validate:"min=1,max=100"`
    Search string   `query:"q"`
    Tags   []string `query:"tags"` // ?tags=a&tags=b  or  ?tags=a,b
}

func listUsers(w http.ResponseWriter, r *http.Request) error {
    var p Params
    if err := kori.BindQuery(r, &p); err != nil {
        return err
    }
    // p.Page, p.Limit, p.Search, p.Tags populated and validated
}
kori.BindPath(r, &dst)

Populates a struct from Chi's URL params using the path tag.

type Params struct {
    ID   string `path:"id"   validate:"required,uuid4"`
    Slug string `path:"slug" validate:"required"`
}

func getPost(w http.ResponseWriter, r *http.Request) error {
    var p Params
    if err := kori.BindPath(r, &p); err != nil {
        return err
    }
    // p.ID, p.Slug populated
}

For a single path param, prefer chi.URLParam directly. BindPath is worth it when there are multiple params or validation.

kori.BindHeader(r, &dst)

Populates a struct from headers using the header tag.

type Params struct {
    Token   string `header:"X-Auth-Token" validate:"required"`
    Version string `header:"X-Api-Version"`
}

func handler(w http.ResponseWriter, r *http.Request) error {
    var p Params
    if err := kori.BindHeader(r, &p); err != nil {
        return err
    }
}
kori.BindJSON(r, &dst)

Decodes the JSON body and validates the struct.

type Body struct {
    Title    string `json:"title"    validate:"required,min=1,max=200"`
    Priority string `json:"priority" validate:"required,oneof=low medium high"`
    Done     *bool  `json:"done"`    // pointer = optional field
}

func createTodo(w http.ResponseWriter, r *http.Request) error {
    var body Body
    if err := kori.BindJSON(r, &body); err != nil {
        return err // 400 if invalid JSON, 422 if validation fails
    }
}
kori.Bind(r, &dst)

Combines path, query, and header in a single call. Useful when a struct mixes sources.

type Params struct {
    ID   string `path:"id"`
    Page int    `query:"page"`
    Auth string `header:"Authorization"`
}

func handler(w http.ResponseWriter, r *http.Request) error {
    var p Params
    if err := kori.Bind(r, &p); err != nil {
        return err
    }
}
Supported types

string, int / int8-64, uint / uint8-64, float32 / float64, bool, pointers to any of these, and slices (query only).

Validation tags

Any go-playground/validator tag works:

validate:"required"
validate:"required,min=2,max=100"
validate:"required,email"
validate:"required,uuid4"
validate:"omitempty,oneof=low medium high"
validate:"min=1,max=100"

Validation errors return 422 with per-field details:

{
  "message": "validation failed",
  "details": [
    { "field": "email", "tag": "email", "message": "email must be a valid email address" },
    { "field": "name",  "tag": "min",   "param": "2", "message": "name must be at least 2" }
  ]
}

Responses

All are simple functions that take w:

kori.JSON(w, http.StatusOK, v)           // application/json
kori.JSON(w, http.StatusCreated, v)
kori.RawJSON(w, http.StatusOK, []byte{}) // pre-serialized JSON (cache, etc.)
kori.Text(w, http.StatusOK, "pong")      // text/plain
kori.NoContent(w)                        // 204, no body
kori.Redirect(w, r, http.StatusSeeOther, "/login")

Errors

// Ready-made constructors
return kori.BadRequest("invalid input")
return kori.Unauthorized("missing token")
return kori.Forbidden("insufficient permissions")
return kori.NotFound("user not found")
return kori.Conflict("email already in use")
return kori.UnprocessableEntity("validation failed")
return kori.InternalServerError("unexpected error")

// With optional details
return kori.NewError(http.StatusTooManyRequests, "rate limit exceeded", map[string]int{
    "retry_after": 60,
})

// Generic error → 500 automatically
return fmt.Errorf("db.Query: %w", err)
Custom error handler
kori.SetErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
    var he *kori.HTTPError
    if !errors.As(err, &he) {
        he = kori.InternalServerError("unexpected error")
        slog.Error("unhandled", "err", err, "path", r.URL.Path)
    }

    // RFC 7807 Problem Details
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(he.Status)
    json.NewEncoder(w).Encode(map[string]any{
        "type":   "https://api.example.com/errors",
        "status": he.Status,
        "detail": he.Message,
    })
})

Route registration

kori.GET(r, "/users", listUsers)
kori.POST(r, "/users", createUser)
kori.PUT(r, "/users/{id}", replaceUser)
kori.PATCH(r, "/users/{id}", updateUser)
kori.DELETE(r, "/users/{id}", deleteUser)
kori.HEAD(r, "/users/{id}", headUser)
kori.OPTIONS(r, "/users", optionsUsers)

All take a chi.Router — any chi.Router works, including sub-routers.


Groups

// Prefix only
api := kori.Group(r, "/api/v1")
kori.GET(api, "/users", listUsers)

// Prefix + group middlewares
admin := kori.Group(r, "/admin", AuthMiddleware, AuditMiddleware)
kori.DELETE(admin, "/users/{id}", deleteUser)

// Nested
v2 := kori.Group(api, "/v2")
kori.GET(v2, "/users", listUsersV2)

Per-route middlewares

// Global (applied on the router — standard Chi)
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)

// Per-route (applied only to this route)
kori.DELETE(r, "/admin/users/{id}", deleteUser,
    kori.Use(AuthMiddleware, AuditMiddleware),
)

// Per-group
admin := kori.Group(r, "/admin", AuthMiddleware)

Options

Option is Kori's extension point. It is a function executed at route registration time, receiving *RouteInfo with method and pattern.

type Option func(*RouteInfo)

// Internal use — kori.Use()
// External use — kori/openapi (v0.2.0), route logging, etc.

// Example: log all routes at startup
func LogRoute(log *slog.Logger) kori.Option {
    return func(ri *kori.RouteInfo) {
        log.Info("route registered",
            "method", ri.Method,
            "pattern", ri.Pattern,
        )
    }
}

kori.GET(r, "/users", listUsers, LogRoute(log))

Coexistence with plain Chi

Kori doesn't replace Chi — it coexists. Standard Chi handlers and Kori handlers can share the same router:

r := chi.NewRouter()
r.Use(chimiddleware.Logger) // standard Chi middleware

// Standard Chi handler
r.Get("/legacy", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("legacy"))
})

// Kori handler on the same router
kori.GET(r, "/users", listUsers)
kori.POST(r, "/users", createUser)

Full example

See examples/todos/main.go — a TODOs API with:

  • BindQuery with validation and filtering
  • chi.URLParam directly for simple path params
  • BindJSON with typed and validated body
  • Per-route middleware (kori.Use)
  • Group with prefix
  • Implicit error handler
cd examples/todos && go run .

curl http://localhost:8080/health
curl "http://localhost:8080/api/todos?page=1&page_size=10"
curl -X POST http://localhost:8080/api/todos \
     -H "Content-Type: application/json" \
     -d '{"title":"Ship Kori"}'
curl http://localhost:8080/api/todos/1
curl -X PATCH http://localhost:8080/api/todos/1 \
     -H "Content-Type: application/json" \
     -d '{"completed":true}'
curl -X DELETE http://localhost:8080/api/todos/1

Roadmap

  • v0.2kori/openapi: OpenAPI 3.1 spec generation via Option, separate module
  • v0.3kori/testing: test helpers for handlers, BindMultipart for multipart/form-data

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Bind

func Bind(r *http.Request, dst any) error

func BindHeader

func BindHeader(r *http.Request, dst any) error

func BindJSON

func BindJSON(r *http.Request, dst any) error

func BindPath

func BindPath(r *http.Request, dst any) error

func BindQuery

func BindQuery(r *http.Request, dst any) error

func DELETE

func DELETE(r chi.Router, pattern string, h Handler, opts ...Option)

func GET

func GET(r chi.Router, pattern string, h Handler, opts ...Option)

func Group

func Group(r chi.Router, prefix string, middlewares ...Middleware) chi.Router
func HEAD(r chi.Router, pattern string, h Handler, opts ...Option)

func JSON

func JSON(w http.ResponseWriter, status int, v any) error

func NoContent

func NoContent(w http.ResponseWriter) error

func OPTIONS

func OPTIONS(r chi.Router, pattern string, h Handler, opts ...Option)

func PATCH

func PATCH(r chi.Router, pattern string, h Handler, opts ...Option)

func POST

func POST(r chi.Router, pattern string, h Handler, opts ...Option)

func PUT

func PUT(r chi.Router, pattern string, h Handler, opts ...Option)

func RawJSON

func RawJSON(w http.ResponseWriter, status int, data []byte) error

func Redirect

func Redirect(w http.ResponseWriter, r *http.Request, status int, url string) error

func SetErrorHandler

func SetErrorHandler(h ErrorHandler)

func Text

func Text(w http.ResponseWriter, status int, s string) error

Types

type ErrorHandler

type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)

func GetErrorHandler

func GetErrorHandler() ErrorHandler

type HTTPError

type HTTPError struct {
	Status  int    `json:"-"`
	Message string `json:"message"`
	Details any    `json:"details,omitempty"`
}

func BadRequest

func BadRequest(msg string, details ...any) *HTTPError

func Conflict

func Conflict(msg string, details ...any) *HTTPError

func Forbidden

func Forbidden(msg string, details ...any) *HTTPError

func InternalServerError

func InternalServerError(msg string, details ...any) *HTTPError

func NewError

func NewError(status int, message string, details ...any) *HTTPError

func NotFound

func NotFound(msg string, details ...any) *HTTPError

func Unauthorized

func Unauthorized(msg string, details ...any) *HTTPError

func UnprocessableEntity

func UnprocessableEntity(msg string, details ...any) *HTTPError

func (*HTTPError) Error

func (e *HTTPError) Error() string

type Handler

type Handler func(w http.ResponseWriter, r *http.Request) error

type Middleware

type Middleware = func(http.Handler) http.Handler

type Option

type Option func(*RouteInfo)

func Use

func Use(middlewares ...Middleware) Option

type RouteInfo

type RouteInfo struct {
	Method  string
	Pattern string
	// contains filtered or unexported fields
}

type ValidationDetail

type ValidationDetail struct {
	Field   string `json:"field"`
	Tag     string `json:"tag"`
	Param   string `json:"param,omitempty"`
	Message string `json:"message"`
}

Directories

Path Synopsis
examples
todos command
openapi module

Jump to

Keyboard shortcuts

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