Skip to content

Latest commit

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

restapi

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.

Features

  • Uniform JSON envelope{data, pagination, errors} with omitempty on 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 everywhereWithErrorRegistry, WithMessage, WithDetails, WithPagination, WithErrors. Every option is nil-safe.
  • Default API with hot-swapDefault() is an atomic.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 == nil or c.Writer.Written(); symmetric nil-body handling (never writes the literal string null).
  • 100%-tested, race-clean.

Install

go get github.com/Arhius/restapi

Requires Go 1.26.2+.

Quickstart

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"}]}

Concepts

  • 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.

Default error codes

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.

CodeValueHTTPMessage
CodeBadRequest1000400bad request
CodeUnauthorized1001401unauthorized
CodeForbidden1002403forbidden
CodeNotFound1003404resource not found
CodeConflict1004409conflict
CodeValidationFailed1005422validation failed
CodeTooManyRequests1006429too many requests
CodeInternal1007500internal error (fallback — not registered)
CodeUnavailable1008503service unavailable

Extending the registry

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

Options

APIOption

OptionDescription
WithErrorRegistry(*ErrorRegistry)Binds the API to a custom registry. Nil = no-op; NewAPI falls back to Default().Registry().

ErrorOption

OptionDescription
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).

ResponseBodyOption

OptionDescription
WithPagination(any)Sets the envelope's pagination field.
WithErrors(...ErrorResponse)Appends to the envelope's errors slice. Empty variadic call is a no-op.

Response helpers

HelperAborts chainBody shape
Success(c, status, data, opts...)no{data, pagination?, errors?}
SuccessBody(c, status, body)nocaller-supplied
Error(c, code, opts...)no{errors: [{…}]} (status from registry)
ErrorBody(c, status, body)nocaller-supplied
Abort(c, code, opts...)yes{errors: [{…}]} (status from registry)
AbortBody(c, status, body)yescaller-supplied
WriteResponse(c, status, abort, body)optionallow-level escape hatch

All helpers are a no-op when c == nil or when the response has already been written (c.Writer.Written()).

Pagination

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

Errors

Sentinel errors, matched with errors.Is:

  • ErrUnknownErrorCode — returned by MustLookup when the code is not registered. Wrapped with the offending code.
  • ErrCodeAlreadyRegistered — returned by Register on duplicate codes (across calls or within a batch). Wrapped with the offending code.
  • ErrFallbackErrorCodeRegistration — returned by Register when a caller tries to register the registry's fallback code.
  • ErrInvalidPaginationLimit, ErrInvalidPaginationOffset, ErrInvalidPaginationPageSize, ErrInvalidPaginationPageNumber — returned by the Validate() methods on the pagination request types.
if_, err:=restapi.Default().MustLookup(2001); errors.Is(err, restapi.ErrUnknownErrorCode) {
// code 2001 is not registered
}

Thread safety

  • API is safe for concurrent use once constructed.
  • ErrorRegistry is sync.RWMutex-guarded; Register, Lookup, and MustLookup are all safe under concurrent callers.
  • Default() reads an atomic.Pointer[API], so swapping it via SetDefault from one goroutine while other goroutines call Default() is race-free.

Development

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 .

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages