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"
)
funcmain() {
router:=gin.Default()
router.GET("/users/:id", func(c*gin.Context) {
user, err:=findUser(c.Param("id"))
iferr!=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+iotaCodeQuotaExceeded
)
funcinit() {
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"},
// …
)
iferr!=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).typeOffsetPaginationRequeststruct {
Limitint`form:"limit" binding:"required,gt=0"`Offsetint`form:"offset" binding:"gte=0"`
}
// Page/size — PageNumber is optional (defaults to 0 when omitted).typePagePaginationRequeststruct {
PageSizeint64`form:"pageSize" binding:"required,gt=0"`PageNumberint64`form:"pageNumber" binding:"gte=0"`
}
// Cursor — empty Cursor is the first page.typeCursorPaginationRequeststruct {
Cursorstring`form:"cursor"`Limitint`form:"limit" binding:"required,gt=0"`
}Usage:
router.GET("/items", func(c*gin.Context) {
varreq restapi.OffsetPaginationRequestiferr:=c.ShouldBindQuery(&req); err!=nil {
restapi.Error(c, restapi.CodeBadRequest, restapi.WithDetails(err.Error()))
return
}
items, total, err:=list(req.Limit, req.Offset)
iferr!=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 .