kori

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 13 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, BindForm, BindMultipart
  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.26+.


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
    }
}
kori.BindForm(r, &dst)

Populates a struct from URL-encoded form values using the form tag.

type ContactInput struct {
    Name      string `form:"name"      validate:"required,min=2,max=100"`
    Email     string `form:"email"     validate:"required,email"`
    Message   string `form:"message"   validate:"required,min=10,max=1000"`
    Subscribe bool   `form:"subscribe"`
}

func handleContact(w http.ResponseWriter, r *http.Request) error {
    var input ContactInput
    if err := kori.BindForm(r, &input); err != nil {
        return err
    }
}
kori.BindMultipart(r, &dst)

Decodes multipart/form-data — both form values and file uploads — using the form tag.

type ProfileInput struct {
    DisplayName string                `form:"display_name" validate:"required,min=2,max=50"`
    Avatar      *multipart.FileHeader `form:"avatar"       validate:"required"`
}

type GalleryInput struct {
    Title  string                    `form:"title"  validate:"required"`
    Tags   []string                  `form:"tags"`
    Photos []*multipart.FileHeader   `form:"photos" validate:"required,min=1"`
}

func handleProfile(w http.ResponseWriter, r *http.Request) error {
    var input ProfileInput
    if err := kori.BindMultipart(r, &input); err != nil {
        return err
    }
    // input.Avatar is *multipart.FileHeader
}

func handleGallery(w http.ResponseWriter, r *http.Request) error {
    var input GalleryInput
    if err := kori.BindMultipart(r, &input); err != nil {
        return err
    }
    // input.Photos is []*multipart.FileHeader
}

File fields must be *multipart.FileHeader (single upload) or []*multipart.FileHeader (multiple uploads). Non-file fields follow the same rules as BindForm.

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, 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)

Examples

  • examples/todos/main.go — a TODOs API with BindQuery, BindJSON, per-route middleware, groups, and implicit error handling.

  • examples/forms/main.go — a form demo with BindForm (contact form) and BindMultipart (profile + gallery uploads with *multipart.FileHeader and []*multipart.FileHeader).

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

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrStreamingNotSupported = errors.New("kori: response writer does not support streaming (does not implement http.Flusher)")

ErrStreamingNotSupported is returned when the ResponseWriter does not implement http.Flusher and therefore cannot be used for SSE.

Functions

func Bind

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

Bind decodes path, query, and header params into dst and validates it. Convenience wrapper around BindPath + BindQuery + BindHeader.

func BindForm added in v0.4.0

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

BindForm decodes form values from r.PostForm into dst and validates it. dst must be a pointer to a struct with `form` tags.

func BindHeader

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

BindHeader decodes HTTP headers into dst and validates it. dst must be a pointer to a struct with `header` tags.

func BindJSON

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

BindJSON decodes the request body as JSON into dst and validates it. Body is limited to 4 MB by default; configure it with SetMaxBodyBytes. Returns a 413 HTTPError if the body exceeds the limit and a 422 HTTPError on validation failure.

func BindMultipart added in v0.4.0

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

BindMultipart decodes multipart form data (values and files) into dst and validates it. dst must be a pointer to a struct with `form` tags. File fields must be of type *multipart.FileHeader or []*multipart.FileHeader.

func BindPath

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

BindPath decodes chi URL parameters into dst and validates it. dst must be a pointer to a struct with `path` tags.

var params UserParams
kori.BindPath(r, &params)

func BindQuery

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

BindQuery decodes URL query parameters into dst and validates it. dst must be a pointer to a struct with `query` tags.

var params ListUsersParams
kori.BindQuery(r, &params)

func DELETE

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

DELETE registers a handler for the DELETE method.

func GET

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

GET registers a handler for the GET method.

kori.GET(r, "/users", listUsers)
kori.GET(r, "/users/{id}", getUser, kori.Use(adminOnly))
func HEAD(r chi.Router, pattern string, h Handler, opts ...Option)

HEAD registers a handler for the HEAD method.

func JSON

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

JSON writes v as JSON with the given status code.

func NoContent

func NoContent(w http.ResponseWriter) error

NoContent writes a 204 No Content response.

func OPTIONS

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

OPTIONS registers a handler for the OPTIONS method.

func PATCH

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

PATCH registers a handler for the PATCH method.

func POST

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

POST registers a handler for the POST method.

func PUT

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

PUT registers a handler for the PUT method.

func RawJSON

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

RawJSON writes pre-encoded JSON bytes with the given status code.

func Redirect

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

Redirect sends an HTTP redirect response. status should be http.StatusMovedPermanently, http.StatusFound, etc.

func SetErrorHandler

func SetErrorHandler(h ErrorHandler)

SetErrorHandler replaces the default error handler. The handler receives every error returned from a Handler.

SetErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
    w.Write([]byte("custom error: " + err.Error()))
})

func SetMaxBodyBytes added in v0.5.0

func SetMaxBodyBytes(n int64)

SetMaxBodyBytes sets the maximum size of a JSON request body. Default is 4 MB.

func SetMaxMultipartMemory added in v0.4.0

func SetMaxMultipartMemory(n int64)

SetMaxMultipartMemory sets the maximum memory size for multipart form parsing. Default is 32 MB.

func Text

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

Text writes a plain text response with the given status code.

Types

type ErrorHandler

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

ErrorHandler is a function that writes an error response. Replace the default with SetErrorHandler to customize error handling.

func GetErrorHandler

func GetErrorHandler() ErrorHandler

GetErrorHandler returns the current error handler.

type HTTPError

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

HTTPError is a structured HTTP error with status code and optional details. Return it from a Handler to let kori write the error response.

func BadRequest

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

BadRequest returns a 400 HTTPError.

func Conflict

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

Conflict returns a 409 HTTPError.

func Forbidden

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

Forbidden returns a 403 HTTPError.

func InternalServerError

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

InternalServerError returns a 500 HTTPError.

func NewError

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

NewError creates an HTTPError with the given status and message. An optional details value is included in the JSON response.

func NotFound

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

NotFound returns a 404 HTTPError.

func Unauthorized

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

Unauthorized returns a 401 HTTPError.

func UnprocessableEntity

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

UnprocessableEntity returns a 422 HTTPError.

func (*HTTPError) Error

func (e *HTTPError) Error() string

type Handler

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

Handler is an HTTP handler that returns an error. Errors are caught and handled by the configured ErrorHandler.

type Middleware

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

Middleware is a standard HTTP middleware.

type Option

type Option func(*RouteInfo)

Option configures a route at registration time.

func Use

func Use(middlewares ...Middleware) Option

Use attaches middlewares to a specific route.

kori.GET(r, "/admin", adminHandler, kori.Use(authMiddleware, auditMiddleware))

type RouteInfo

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

RouteInfo contains the parsed route metadata. Used internally by Option functions to configure routes.

type Router added in v0.2.1

type Router struct {
	chi.Router
	// contains filtered or unexported fields
}

Router wraps chi.Router with a prefix for nested route groups.

func Group

func Group(r chi.Router, prefix string, middlewares ...Middleware) *Router

Group creates a sub-router with the given prefix and middlewares.

users := kori.Group(r, "/users", authMiddleware)
kori.GET(users, "/", listUsers)
kori.GET(users, "/{id}", getUser)

type SSEEvent added in v0.3.0

type SSEEvent struct {
	ID    string
	Event string
	Data  string
	Retry int
}

SSEEvent represents a single Server-Sent Event.

type SSEWriter added in v0.3.0

type SSEWriter struct {
	// contains filtered or unexported fields
}

SSEWriter writes Server-Sent Events to an http.ResponseWriter.

func NewSSEWriter added in v0.3.0

func NewSSEWriter(w http.ResponseWriter) (*SSEWriter, error)

NewSSEWriter creates an SSEWriter, sets the SSE headers, and flushes the initial response. Returns ErrStreamingNotSupported if the ResponseWriter does not support flushing.

func (*SSEWriter) Ping added in v0.3.0

func (s *SSEWriter) Ping() error

Ping sends an SSE comment line to keep the connection alive.

Long-lived SSE connections can be silently dropped by proxies, load balancers, or firewalls that close idle TCP connections. Call Ping periodically (e.g. every 15–30 seconds) when no real events are being sent to prevent this.

ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
    select {
    case <-r.Context().Done():
        return nil
    case <-ticker.C:
        stream.Ping()
    case msg := <-messages:
        stream.SendJSON(msg)
    }
}

func (*SSEWriter) Send added in v0.3.0

func (s *SSEWriter) Send(event SSEEvent) error

Send writes event to the stream and flushes immediately.

Returns an error if the underlying write fails, which usually means the client has disconnected. Callers should treat a write error as a normal end-of-stream signal rather than an application error:

if err := stream.Send(event); err != nil {
    return nil // client gone, stop the loop
}

func (*SSEWriter) SendData added in v0.3.0

func (s *SSEWriter) SendData(data string) error

SendData sends a data-only event. Shorthand for Send(SSEEvent{Data: data}).

stream.SendData("hello")
stream.SendData(`{"status":"processing"}`)

func (*SSEWriter) SendJSON added in v0.3.0

func (s *SSEWriter) SendJSON(v any) error

SendJSON encodes v as JSON and sends it as a data-only event. Ideal for structured streaming payloads (LLM tokens, progress updates, etc.).

stream.SendJSON(map[string]string{"text": token})

To set an event name alongside JSON data, encode manually and use Send:

data, _ := json.Marshal(payload)
stream.Send(kori.SSEEvent{Event: "token", Data: string(data)})

type ValidationDetail

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

ValidationDetail describes a single field validation error. Returned as Details in the 422 HTTPError response.

Directories

Path Synopsis
examples
forms command
sse command
todos command
openapi module

Jump to

Keyboard shortcuts

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