DEV Community

Muhammad Saleh Solahudin
Muhammad Saleh Solahudin

Posted on

I built RespKit to make Go API responses easier to manage

#go

I've been working on RespKit, a Go package for keeping HTTP responses consistent across an application.

The idea came from something pretty ordinary: writing the same response-formatting and error-handling code in different handlers. One endpoint returns data, another returns result, and error responses gradually pick up their own formats too.

I wanted a shared place to handle those decisions, with simple defaults and room to customize things as an application grows.

Repo: github.com/ZihxS/RespKit
Docs: zihxs.github.io/RespKit

Start with an ordinary Go handler

The core package uses only Go's standard library. Install it in an existing Go module:

go get github.com/ZihxS/RespKit/resp
Enter fullscreen mode Exit fullscreen mode

Here's a small net/http server with a success route and a missing-resource route:

package main

import (
    "log"
    "net/http"

    "github.com/ZihxS/RespKit/resp"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
        user := map[string]any{
            "user_id": 123,
            "name":    "ZihxS",
        }
        if err := resp.OK(resp.HTTP(w, r), user); err != nil {
            log.Printf("write response: %v", err)
        }
    })

    mux.HandleFunc("/users/missing", func(w http.ResponseWriter, r *http.Request) {
        if err := resp.Fail(resp.HTTP(w, r), resp.ErrNotFound); err != nil {
            log.Printf("write response: %v", err)
        }
    })

    log.Fatal(http.ListenAndServe(":8080", mux))
}
Enter fullscreen mode Exit fullscreen mode

Save it as main.go and run go run main.go. There's no RespKit configuration required for either handler.

A request to /users/123 returns HTTP 200 and this envelope:

{
  "success": true,
  "data": {
    "user_id": 123,
    "name": "ZihxS"
  }
}
Enter fullscreen mode Exit fullscreen mode

For an English error response, try:

curl -i -H 'Accept-Language: en' http://localhost:8080/users/missing
Enter fullscreen mode Exit fullscreen mode

That returns HTTP 404 with:

{
  "success": false,
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "The requested resource was not found."
  }
}
Enter fullscreen mode Exit fullscreen mode

The public code stays the same when the message language changes, so clients can handle RESOURCE_NOT_FOUND without depending on a particular sentence.

There are also helpers for 201 Created, 204 No Content, and 404 Not Found: resp.Created, resp.NoContent, and resp.NotFound. A created response can include a Location header, and 204 responses have no body.

Keep using your existing framework

RespKit works with Gin, Echo, Fiber, Chi, net/http, Gorilla/mux, and Beego.

Gin, Echo, and Fiber accept their native contexts. For example, a Gin handler can pass its context directly:

func handler(c *gin.Context) {
    user := gin.H{"user_id": 123, "name": "ZihxS"}
    if err := resp.OK(c, user); err != nil {
        _ = c.Error(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

With net/http, Chi, Gorilla/mux, and Beego, you pass the response writer and request through resp.HTTP(w, r), as in the first example. The core doesn't import those framework packages.

Customize the defaults around your application

When you need your own settings, you can create a dedicated engine. This example belongs in application startup, before serving requests:

api, err := resp.New(
    resp.WithNaming(resp.CamelCase),
    resp.WithDefaultLocale("en"),
    resp.WithTranslations(map[string]map[string]string{
        "en": {
            "USER_SUSPENDED": "Your account is suspended.",
        },
        "fr": {
            "USER_SUSPENDED": "Votre compte est suspendu.",
        },
    }),
)
if err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

That gives this engine an English default and adds an application-specific message in English and French. You can override existing messages or add more language catalogs the same way.

The naming options are snake_case, camelCase, and PascalCase. They affect RespKit-owned keys, including metadata and validation-detail keys. Your application's data keeps its own JSON keys.

Each engine keeps its configuration after creation and is safe for concurrent use. You can create separate engines for APIs with different settings. If one shared configuration suits your application, resp.Configure(...) updates the package-level helpers instead.

Add your own public errors

You can define domain-specific error codes and HTTP statuses with resp.NewError.

For the engine above, a suspended-account handler could use:

func suspendedHandler(api *resp.Engine) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        failure := resp.NewError("USER_SUSPENDED", http.StatusForbidden)
        if err := api.Fail(resp.HTTP(w, r), failure); err != nil {
            log.Printf("write response: %v", err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The response gets HTTP 403, the stable code USER_SUSPENDED, and the message selected for the request's language.

If you need to retain an underlying error, resp.WithCause(originalErr) attaches it for server-side inspection and logging. Raw internal errors stay private by default. Custom codes without a registered message get a safe, status-appropriate fallback message.

There are built-in mappings for common conditions such as invalid requests, authentication failures, missing resources, conflicts, validation failures, rate limits, and unavailable services. Wrapped errors are handled through errors.Is and errors.As; the built-in resolver doesn't guess from error-message strings.

You can also register custom mappers with resp.WithMapper. A mapper receives an error and returns a public definition containing its code and status when it recognizes that error. This lets you connect your own domain errors or typed errors from a dependency to RespKit's response flow.

Return useful validation details

For validation responses, you can attach details for individual fields:

validationErr := resp.NewError(
    "VALIDATION_FAILED",
    http.StatusUnprocessableEntity,
    resp.WithDetails(
        resp.FieldError{Field: "email", Code: "FIELD_REQUIRED"},
        resp.FieldError{
            Field:  "name",
            Code:   "MIN_LENGTH",
            Params: map[string]string{"min": "3"},
        },
    ),
)
Enter fullscreen mode Exit fullscreen mode

Pass that error to api.Fail in your handler. With the default envelope, clients get the field, its stable error code, and a localized message for each detail. Translation parameters support messages such as a minimum-length requirement without assembling those sentences in the handler.

RespKit formats the details you supply; your application or validation library still performs the validation.

Add languages and choose an error format

Localization follows each request's Accept-Language header, with fallback to the configured default language. You can add catalogs for the languages your application supports, replace the wording of existing messages, and override the language for one response with resp.Locale(...).

RespKit sets Content-Language and Vary: Accept-Language too. When a selected catalog is missing a needed message and the default has it, the response falls back to the default language so its error messages stay consistent.

If your API uses RFC 9457 Problem Details, enable it with resp.WithErrorFormat(resp.ProblemDetails) when creating an engine. A missing-resource error can then look like:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "The requested resource was not found.",
  "code": "RESOURCE_NOT_FOUND"
}
Enter fullscreen mode Exit fullscreen mode

That format applies to error responses and uses application/problem+json. Successful responses keep their normal envelope. Field-level validation details are part of the envelope format shown earlier.

Optional integrations and a few extras

The core recognizes PostgreSQL's unique-constraint SQLSTATE without importing a database driver. There's also an optional pgx/PostgreSQL adapter for additional mappings, including constraint violations and some database availability errors.

The adapter lives in a separate Go module, so its dependencies are only included when you use it. You can register it on an engine with pgxmapper.Option(), and add your own mappings through the same mapper API.

A few other things that are available:

  • Request metadata: a safe X-Request-ID can be included in the response to help connect it with server logs.
  • Response headers: use resp.Location(...) for 201 responses or resp.WithHeader(...) on application errors for headers such as Retry-After and WWW-Authenticate.
  • HTTP behavior: HEAD requests and 204 responses have no body. A 401 response includes an authentication challenge, which applications can customize.
  • Error inspection: resp.Inspect(err) returns classification metadata, and resp.Explain(err) returns a JSON explanation of how an error was resolved.
  • Built-in catalogs: resp.Catalog() exposes public built-in codes, HTTP statuses, and messages. The CLI also provides version, doctor, catalog, and explain commands.

The core stays standard-library-only as you customize the engine. Dependency-specific integrations have their own modules and requirements.

I've tried to keep the common handler calls small while leaving the error codes, messages, mappings, and configuration open to extension.

If you're building APIs in Go, I'd love to hear how you handle this in your projects. What would you change about the response format or configuration? Are there any integrations you'd find useful? :)

Top comments (0)