rr

package module
v1.0.2 Latest Latest
Warning

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

Go to latest
Published: Jul 26, 2026 License: MIT Imports: 5 Imported by: 0

README

rr

Small, zero dependencies, framework-agnostic package for HTTP request parsing and consistent JSON responses. rr handles the boilerplate every endpoint repeats:

  • computing query offset
  • clamping bounds
  • whitelisting sort columns
  • building the JSON response

Before vs after

// Without rr — 45+ lines of manual parsing, bounds checking, error handling, and response building
func handleListUsers(w http.ResponseWriter, r *http.Request) {
    page := 1
    if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
        page = p
    }

    limit := 10
    if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil {
        if l > 100 { l = 100 }
        limit = l
    }

    sortBy := r.URL.Query().Get("sortBy")
    order := r.URL.Query().Get("order")
    isDesc := strings.ToLower(order) == "desc"

    allowedSorts := []string{"name", "created_at"}
    sortValid := false
    for _, s := range allowedSorts {
        if s == sortBy { sortValid = true; break }
    }
    if !sortValid { sortBy = "created_at" }

    offset := (page - 1) * limit
    users, total, err := service.List(r.Context(), offset, limit, sortBy, isDesc)
    if err != nil {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(map[string]interface{}{
            "success": false,
            "error":   map[string]interface{}{"code": 500, "message": "internal error"},
        })
        return
    }

    totalPages := int((total + int64(limit) - 1) / int64(limit))
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "success": true, "msg": "ok", "data": users,
        "meta": map[string]interface{}{
            "page": page, "per_page": limit,
            "total_count": total, "total_pages": totalPages,
        },
    })
}
// net/http With rr — 10 lines
func handleListUsers(w http.ResponseWriter, r *http.Request) {
    list := rr.ParseListFromRequest(r)
    sortP := list.SortParams.WithAllowedSort([]string{"name", "created_at"}, "created_at")

    users, total, err := service.List(r.Context(), list.GetOffset(), list.GetLimit(), sortP.GetSortBy(), sortP.IsDescending())
    if err != nil {
        rr.InternalError(w, "internal error")
        return
    }

    rr.WriteOKMeta(w, users, rr.NewMeta(list.GetPage(), list.GetLimit(), total))
}
// Gin With rr — 10 lines
func handleListUsers(c *gin.Context) {
    list := rr.ParseListFromRequest(c.Request)
    sortP := list.SortParams.WithAllowedSort([]string{"name", "created_at"}, "created_at")

    users, total, err := service.List(c.Request.Context(), list.GetOffset(), list.GetLimit(), sortP.GetSortBy(), sortP.IsDescending())
    if err != nil {
        rr.InternalError(c.Writer, "internal error")
        return
    }

    rr.WriteOKMeta(c.Writer, users, rr.NewMeta(list.GetPage(), list.GetLimit(), total))
}

Quick start

import "github.com/riceball-tw/rr"

// Parse pagination + sorting from request
list := rr.ParseListFromRequest(r)

// Restrict sort fields to a whitelist
sortP := list.SortParams.WithAllowedSort([]string{"name", "created_at"}, "created_at")

// Apply to your query
users, total := service.List(list.GetOffset(), list.GetLimit(), sortP.GetSortBy(), sortP.IsDescending())

// Write consistent JSON response
rr.WriteOKMeta(w, users, rr.NewMeta(list.GetPage(), list.GetLimit(), total))

For errors:

// HTTP 400 with business code 40001
rr.WriteError(w, http.StatusBadRequest, 40001, "name is required")

// Or use a shortcut for common HTTP errors
rr.NotFound(w, "user not found")

Install

go get github.com/riceball-tw/rr

Why rr?

  • ~50 lines → ~4 lines. No manual query parsing or slicing, or response building.
  • Safe by default. ?limit=9999999 is clamped to Config.MaxLimit. Unknown sort fields fall back to your default. Zeroes/negatives become 1.
  • Consistent envelope. Every response is {success, msg, data, error, meta}. Error payloads carry an app-level code plus HTTP status.
  • Marshal safe. If json.Marshal panics, rr writes a proper 500 instead of a truncated body.
  • Framework agnostic. Works with net/http, chi, gin, echo, fiber (via adaptor), and anything accepting http.ResponseWriter.
  • Zero dependencies. Stdlib only.
  • Agent ready. Ships an Agent Skill so AI-written handlers match the intended patterns — see Use with AI agents.

Response format

{
  "success": true,
  "msg": "ok",
  "data": {},
  "meta": { "page": 1, "per_page": 10, "total_count": 42, "total_pages": 5 }
}

Error responses use "success": false and an error object:

{
  "success": false,
  "error": { "code": 40001, "message": "name is required" }
}

Defaults

Page size defaults to 10 and is capped at 100. Override at startup:

rr.Config.DefaultLimit = 20  // default page size
rr.Config.MaxLimit = 500     // hard cap

Query parameters parsed: page, limit, sortBy, isDesc, order. Their names are configurable too (rr.Config.SortByKey = "sort_by").

Use with AI agents

rr ships an Agent Skill (same open format used by Vercel agent-skills and the skills CLI). It teaches agents the canonical list-handler shape, framework recipes for net/http, gin, echo and fiber, and the sharp edges — omitempty dropping the data key on empty pages, pointer-receiver getters that can't be chained, With* returning copies.

The skill lives in this repo (.claude/skills/rr/). It is not part of the vercel-labs/agent-skills collection; install it from riceball-tw/rr with Vercel's skills CLI.

Works with Claude Code, Cursor, Codex, OpenCode, and other supported agents:

# Interactive — pick agents when prompted
npx skills add riceball-tw/rr

# Non-interactive — install the rr skill only
npx skills add riceball-tw/rr --skill rr -y

# Global (every project on this machine)
npx skills add riceball-tw/rr --skill rr -g -y

# Specific agents only
npx skills add riceball-tw/rr --skill rr -a claude-code -a cursor -a codex -y

List what the CLI finds before installing:

npx skills add riceball-tw/rr --list

After install, agents pick the skill up automatically when a task touches list endpoints or JSON responses.

Manual install (Claude Code only)
mkdir -p .claude/skills/rr
curl -sL -o .claude/skills/rr/SKILL.md     https://raw.githubusercontent.com/riceball-tw/rr/main/.claude/skills/rr/SKILL.md
curl -sL -o .claude/skills/rr/reference.md https://raw.githubusercontent.com/riceball-tw/rr/main/.claude/skills/rr/reference.md

Use ~/.claude/skills/rr/ instead to install it for every project.

Development

go test ./...

Runnable examples in examples/nethttp and examples/gin. The agent skill lives in .claude/skills/rr/.

License

MIT

Documentation

Overview

Package rr provides unified, framework-agnostic HTTP request helpers and response envelopes.

Request helpers cover list-endpoint concerns (pagination, sorting) and work from url.Values or *http.Request. Struct tags use standard form / json / query keys so the same types bind in gin, echo, fiber, and similar frameworks.

Response types and builders have no framework dependency. Write helpers use the standard library's http.ResponseWriter, so they plug into net/http, chi, gorilla/mux, gin, echo, fiber (via adaptor), and any other HTTP stack.

Index

Constants

View Source
const (
	DefaultPage  = 1
	DefaultLimit = 10
	DefaultMax   = 100
)

Default pagination values. Override via Config or per-call options.

Variables

View Source
var Config = struct {
	DefaultPage  int
	DefaultLimit int
	MaxLimit     int
	// Query key names used by Parse* helpers.
	PageKey   string
	LimitKey  string
	SortByKey string
	IsDescKey string
	// OrderKey is an alternative to IsDescKey: "asc" / "desc".
	OrderKey string
}{
	DefaultPage:  DefaultPage,
	DefaultLimit: DefaultLimit,
	MaxLimit:     DefaultMax,
	PageKey:      "page",
	LimitKey:     "limit",
	SortByKey:    "sortBy",
	IsDescKey:    "isDesc",
	OrderKey:     "order",
}

Config holds package-level defaults. Safe to mutate at startup only.

Functions

func BadRequest

func BadRequest(w http.ResponseWriter, message string)

BadRequest writes 400.

func Conflict

func Conflict(w http.ResponseWriter, message string)

Conflict writes 409.

func Forbidden

func Forbidden(w http.ResponseWriter, message string)

Forbidden writes 403.

func InternalError

func InternalError(w http.ResponseWriter, message string)

InternalError writes 500.

func JSON

func JSON[T any](resp Response[T]) ([]byte, error)

JSON returns the response as JSON bytes (useful for tests or custom writers).

func NotFound

func NotFound(w http.ResponseWriter, message string)

NotFound writes 404.

func TooManyRequests

func TooManyRequests(w http.ResponseWriter, message string)

TooManyRequests writes 429.

func Unauthorized

func Unauthorized(w http.ResponseWriter, message string)

Unauthorized writes 401.

func UnprocessableEntity

func UnprocessableEntity(w http.ResponseWriter, message string)

UnprocessableEntity writes 422.

func Write

func Write[T any](w http.ResponseWriter, status int, resp Response[T])

Write encodes resp as JSON with the given HTTP status code.

If json.Marshal fails (e.g. circular data, panicking MarshalJSON), it writes a 500 Internal Server Error fallback instead of silently returning a partial or empty body under the original status code.

func WriteError

func WriteError(w http.ResponseWriter, status, code int, message string)

WriteError writes a failed response with the given HTTP status and error body. status is the HTTP status code; code is the application error code in the body.

func WriteOK

func WriteOK[T any](w http.ResponseWriter, data T)

WriteOK writes a 200 success response.

func WriteOKMeta

func WriteOKMeta[T any](w http.ResponseWriter, data T, meta *Meta)

WriteOKMeta writes a 200 success response with metadata.

func WriteOKMsg

func WriteOKMsg[T any](w http.ResponseWriter, data T, msg string)

WriteOKMsg writes a 200 success response with a custom message.

func WriteOKMsgMeta

func WriteOKMsgMeta[T any](w http.ResponseWriter, data T, msg string, meta *Meta)

WriteOKMsgMeta writes a 200 success response with a custom message and metadata.

Types

type Error

type Error struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

Error holds application-level error details inside the envelope. Code is free-form (HTTP status, business code, or both — your convention).

type ListParams

type ListParams struct {
	PaginationParams
	SortParams
}

ListParams combines pagination and sorting for typical list endpoints.

func ParseList

func ParseList(q url.Values) ListParams

ParseList reads both pagination and sort params from url.Values.

func ParseListFromRequest

func ParseListFromRequest(r *http.Request) ListParams

ParseListFromRequest reads list params from r.URL.Query().

type Meta

type Meta struct {
	Page       int   `json:"page,omitempty"`
	PerPage    int   `json:"per_page,omitempty"`
	TotalCount int64 `json:"total_count,omitempty"`
	// TotalPages is computed when both PerPage and TotalCount are set via NewMeta.
	TotalPages int `json:"total_pages,omitempty"`
}

Meta holds optional response metadata such as pagination.

func NewMeta

func NewMeta(page, perPage int, totalCount int64) *Meta

NewMeta builds pagination metadata and fills TotalPages when possible.

type PaginationParams

type PaginationParams struct {
	Page  int `form:"page" json:"page" query:"page"`
	Limit int `form:"limit" json:"limit" query:"limit"`
	// MaxLimit caps GetLimit(). Zero means use Config.MaxLimit.
	MaxLimit int `form:"-" json:"-" query:"-"`
}

PaginationParams is a reusable page/limit pair for list endpoints.

Embed into your own request DTOs:

type ListUsersReq struct {
    rr.PaginationParams
    Status string `form:"status" json:"status" query:"status"`
}

func ParsePagination

func ParsePagination(q url.Values) PaginationParams

ParsePagination reads page/limit from url.Values (query or form).

func ParsePaginationFromRequest

func ParsePaginationFromRequest(r *http.Request) PaginationParams

ParsePaginationFromRequest reads pagination from r.URL.Query().

func (*PaginationParams) GetLimit

func (p *PaginationParams) GetLimit() int

GetLimit returns a safe page size, clamped to MaxLimit.

func (*PaginationParams) GetOffset

func (p *PaginationParams) GetOffset() int64

GetOffset is an alias of GetSkip for naming conventions that prefer "offset".

func (*PaginationParams) GetPage

func (p *PaginationParams) GetPage() int

GetPage returns a safe page number (>= 1).

func (*PaginationParams) GetSkip

func (p *PaginationParams) GetSkip() int64

GetSkip returns the offset for SQL OFFSET / Mongo skip style queries.

func (PaginationParams) WithMaxLimit

func (p PaginationParams) WithMaxLimit(max int) PaginationParams

WithMaxLimit returns a copy of p with MaxLimit set.

type Response

type Response[T any] struct {
	Success bool   `json:"success"`
	Msg     string `json:"msg,omitempty"`
	Data    T      `json:"data,omitempty"`
	Error   *Error `json:"error,omitempty"`
	Meta    *Meta  `json:"meta,omitempty"`
}

Response is the unified API response envelope.

Example success:

{"success":true,"msg":"ok","data":{...},"meta":{...}}

Example error:

{"success":false,"error":{"code":400,"message":"invalid id"}}

func Fail

func Fail(code int, message string) Response[any]

Fail builds a failed response with an error payload.

func FailData

func FailData[T any](code int, message string, data T) Response[T]

FailData builds a failed response that also carries partial data.

func OK

func OK[T any](data T) Response[T]

OK builds a successful response with data.

func OKMeta

func OKMeta[T any](data T, meta *Meta) Response[T]

OKMeta builds a successful response with data and metadata.

func OKMsg

func OKMsg[T any](data T, msg string) Response[T]

OKMsg builds a successful response with a custom message.

func OKMsgMeta

func OKMsgMeta[T any](data T, msg string, meta *Meta) Response[T]

OKMsgMeta builds a successful response with a custom message and metadata.

type SortParams

type SortParams struct {
	// SortBy is the client-requested sort field.
	SortBy string `form:"sortBy" json:"sortBy" query:"sortBy"`
	// IsDesc requests descending order when true.
	IsDesc bool `form:"isDesc" json:"isDesc" query:"isDesc"`
	// Order is an alternative to IsDesc: "asc", "desc", "descending", or "d".
	// Used by struct-binding frameworks (gin, echo) which read query tags.
	Order string `form:"order" json:"order" query:"order"`
	// AllowedSortBy restricts SortBy to a whitelist. Empty = allow any.
	AllowedSortBy []string `form:"-" json:"-" query:"-"`
	// DefaultSortBy is used when SortBy is empty or not allowed.
	DefaultSortBy string `form:"-" json:"-" query:"-"`
}

SortParams is a reusable sort-by / order pair for list endpoints.

func ParseSort

func ParseSort(q url.Values) SortParams

ParseSort reads sortBy / isDesc (or order) from url.Values.

Supported inputs for direction:

  • isDesc=true|1|yes
  • order=desc|asc (case-insensitive)

func ParseSortFromRequest

func ParseSortFromRequest(r *http.Request) SortParams

ParseSortFromRequest reads sort params from r.URL.Query().

func (*SortParams) GetOrder

func (p *SortParams) GetOrder() string

GetOrder returns "asc" or "desc".

func (*SortParams) GetSortBy

func (p *SortParams) GetSortBy() string

GetSortBy returns a validated sort field (or DefaultSortBy / empty).

func (*SortParams) IsDescending

func (p *SortParams) IsDescending() bool

IsDescending reports whether sort order is descending.

func (SortParams) WithAllowedSort

func (p SortParams) WithAllowedSort(allowed []string, defaultSort string) SortParams

WithAllowedSort returns a copy of p with AllowedSortBy / DefaultSortBy set. Useful after ParseSort:

sort := rr.ParseSort(q).WithAllowedSort([]string{"name","created_at"}, "created_at")

Directories

Path Synopsis
examples
nethttp command
Command nethttp is a small demo API built with net/http + rr.
Command nethttp is a small demo API built with net/http + rr.

Jump to

Keyboard shortcuts

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