Skip to content

Arhius/restapi

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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

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

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.

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

Extending the registry

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

Options

APIOption

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

ErrorOption

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

ResponseBodyOption

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.

Response helpers

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

Pagination

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

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

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Languages