A thin, opinionated layer over Gin that standardises REST response and error envelopes with a functional-options API. Define your error codes once, and every handler in every service returns the same shape.
- Uniform JSON envelope —
{data, pagination, errors}withomitemptyon every field. - Error registry — map application error codes (from 1000 up) to HTTP status and default message.
- Atomic, thread-safe registration — two-pass validation means a batch either fully registers or doesn't mutate the registry at all.
- Functional options everywhere —
WithErrorRegistry,WithMessage,WithDetails,WithPagination,WithErrors. Every option is nil-safe. - Default API with hot-swap —
Default()is anatomic.Pointer[API]so tests and re-configuration never race. - Three pagination shapes — Offset / Page / Cursor, each with
Validate()and a matching Response type. - Safe write path — no-op on
c == nilorc.Writer.Written(); symmetric nil-body handling (never writes the literal stringnull). - 100%-tested, race-clean.
go get github.com/Arhius/restapiRequires Go 1.26.2+.
package main
import (
"net/http"
"github.com/Arhius/restapi"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/users/:id", func(c *gin.Context) {
user, err := findUser(c.Param("id"))
if err != nil {
restapi.Error(c, restapi.CodeNotFound, restapi.WithMessage(err.Error()))
return
}
restapi.Success(c, http.StatusOK, user)
})
router.Run(":8080")
}A successful response:
{"data": {"id": "1", "name": "alice"}}A not-found response (HTTP 404):
{"errors": [{"code": 1003, "message": "user 1 does not exist"}]}API— entry point; bundles the error registry and exposes response helpers.ErrorRegistry— maps application error codes to{StatusCode, ErrorCode, Message}entries. Holds one fallback entry for unknown codes.ResponseBody— the JSON envelope written to clients.
The three concerns are orthogonal: you can use the registry without the response helpers, and vice versa.
The package ships with nine standard HTTP-aligned codes. CodeInternal is the fallback and is not registered — it is returned by any unknown-code lookup.
| Code | Value | HTTP | Message |
|---|---|---|---|
CodeBadRequest |
1000 | 400 | bad request |
CodeUnauthorized |
1001 | 401 | unauthorized |
CodeForbidden |
1002 | 403 | forbidden |
CodeNotFound |
1003 | 404 | resource not found |
CodeConflict |
1004 | 409 | conflict |
CodeValidationFailed |
1005 | 422 | validation failed |
CodeTooManyRequests |
1006 | 429 | too many requests |
CodeInternal |
1007 | 500 | internal error (fallback — not registered) |
CodeUnavailable |
1008 | 503 | service unavailable |
Register your own codes starting at 2000 (to avoid collisions with this package):
const (
CodePaymentRequired = 2000 + iota
CodeQuotaExceeded
)
func init() {
restapi.Default().MustRegister(
restapi.ErrorRegistryEntry{StatusCode: 402, ErrorCode: CodePaymentRequired, Message: "payment required"},
restapi.ErrorRegistryEntry{StatusCode: 507, ErrorCode: CodeQuotaExceeded, Message: "quota exceeded"},
)
}Or build a fresh registry with your own fallback:
fallback := restapi.ErrorRegistryEntry{
StatusCode: http.StatusInternalServerError,
ErrorCode: CodeInternal,
Message: "service error",
}
registry, err := restapi.NewErrorRegistry(
fallback,
restapi.ErrorRegistryEntry{StatusCode: 400, ErrorCode: restapi.CodeBadRequest, Message: "bad request"},
// …
)
if err != nil {
log.Fatal(err)
}
api := restapi.NewAPI(restapi.WithErrorRegistry(registry))
restapi.SetDefault(api) // replace the package-level default| Option | Description |
|---|---|
WithErrorRegistry(*ErrorRegistry) |
Binds the API to a custom registry. Nil = no-op; NewAPI falls back to Default().Registry(). |
| Option | Description |
|---|---|
WithMessage(string) |
Overrides the default Message for this specific response. |
WithDetails(any) |
Attaches structured data to Details (for example, a field-level validation error list). |
| Option | Description |
|---|---|
WithPagination(any) |
Sets the envelope's pagination field. |
WithErrors(...ErrorResponse) |
Appends to the envelope's errors slice. Empty variadic call is a no-op. |
| Helper | Aborts chain | Body shape |
|---|---|---|
Success(c, status, data, opts...) |
no | {data, pagination?, errors?} |
SuccessBody(c, status, body) |
no | caller-supplied |
Error(c, code, opts...) |
no | {errors: [{…}]} (status from registry) |
ErrorBody(c, status, body) |
no | caller-supplied |
Abort(c, code, opts...) |
yes | {errors: [{…}]} (status from registry) |
AbortBody(c, status, body) |
yes | caller-supplied |
WriteResponse(c, status, abort, body) |
optional | low-level escape hatch |
All helpers are a no-op when c == nil or when the response has already been written (c.Writer.Written()).
Three shapes, each with a Validate() method for non-Gin call sites:
// Offset/limit — Offset is optional (defaults to 0 when omitted).
type OffsetPaginationRequest struct {
Limit int `form:"limit" binding:"required,gt=0"`
Offset int `form:"offset" binding:"gte=0"`
}
// Page/size — PageNumber is optional (defaults to 0 when omitted).
type PagePaginationRequest struct {
PageSize int64 `form:"pageSize" binding:"required,gt=0"`
PageNumber int64 `form:"pageNumber" binding:"gte=0"`
}
// Cursor — empty Cursor is the first page.
type CursorPaginationRequest struct {
Cursor string `form:"cursor"`
Limit int `form:"limit" binding:"required,gt=0"`
}Usage:
router.GET("/items", func(c *gin.Context) {
var req restapi.OffsetPaginationRequest
if err := c.ShouldBindQuery(&req); err != nil {
restapi.Error(c, restapi.CodeBadRequest, restapi.WithDetails(err.Error()))
return
}
items, total, err := list(req.Limit, req.Offset)
if err != nil {
restapi.Error(c, restapi.CodeInternal)
return
}
restapi.Success(c, http.StatusOK, items, restapi.WithPagination(req.Response(total)))
})Sentinel errors, matched with errors.Is:
ErrUnknownErrorCode— returned byMustLookupwhen the code is not registered. Wrapped with the offending code.ErrCodeAlreadyRegistered— returned byRegisteron duplicate codes (across calls or within a batch). Wrapped with the offending code.ErrFallbackErrorCodeRegistration— returned byRegisterwhen a caller tries to register the registry's fallback code.ErrInvalidPaginationLimit,ErrInvalidPaginationOffset,ErrInvalidPaginationPageSize,ErrInvalidPaginationPageNumber— returned by theValidate()methods on the pagination request types.
if _, err := restapi.Default().MustLookup(2001); errors.Is(err, restapi.ErrUnknownErrorCode) {
// code 2001 is not registered
}APIis safe for concurrent use once constructed.ErrorRegistryissync.RWMutex-guarded;Register,Lookup, andMustLookupare all safe under concurrent callers.Default()reads anatomic.Pointer[API], so swapping it viaSetDefaultfrom one goroutine while other goroutines callDefault()is race-free.
go test -race -covermode=atomic -coverprofile=cover.out ./... # tests + race
go tool cover -func=cover.out # coverage
go test -run=^$ -bench=. -benchmem ./... # benchmarks
go vet ./...
gofmt -l .