Skip to main content

Build an HTTP service

In this guide, you'll build a complete HTTP service from scratch. You'll start with the basics of defining routes, then level up to type-safe handlers with dependency injection, request binding, and structured responses.

What is httpserver?

The httpserver package is built on top of Gin, one of the most popular HTTP frameworks for Go. It inherits all of Gin's routing, middleware, and parameter handling — so you can always fall back to raw *gin.Context handlers when you need to.

On top of that foundation, httpserver adds a structured, type-safe way to build HTTP services. The With pattern gives you clean dependency injection: each handler group gets its own constructor that receives ctx, config, and logger, so your dependencies are initialized in one place and shared across related routes. The Bind function replaces manual request parsing with struct-tag-driven binding and validation — declare an input struct with json, uri, form, or header tags, and the request data is populated and validated before your handler even runs. Your handlers return a typed Response instead of writing directly to the response writer, making them easy to test and compose.

Together with built-in middleware for logging, metrics, compression, and graceful shutdown — all configured through YAML — httpserver lets you focus on your application logic rather than HTTP plumbing.

Getting started

Install the package:

go get github.com/gosoline-project/httpserver@v0.5.6

The entry point for any HTTP server is a RouterFactory — a function that receives a *Router and registers routes on it:

httpserver.RunDefaultServer(func(ctx context.Context, config cfg.Config, logger log.Logger, router *httpserver.Router) error {
router.GET("/hello", func(ginCtx *gin.Context) {
ginCtx.String(200, "Hello, World!")
})
return nil
})

RunDefaultServer creates a server named "default" reading from the httpserver.default config key. All you need is a config.dist.yml:

httpserver:
default:
port: 8088

You can use standard Gin handlers directly. The Router supports all HTTP methods:

router.GET("/users", listUsers)
router.POST("/users", createUser)
router.PUT("/users/:id", updateUser)
router.DELETE("/users/:id", deleteUser)

Group related routes with router.Group():

api := router.Group("/api")
api.GET("/ping", func(ginCtx *gin.Context) {
ginCtx.JSON(200, gin.H{"message": "pong"})
})

Raw Gin handlers work fine for simple cases. For type safety and cleaner code, read on.

The With pattern

httpserver.With is the recommended way to define routes. It's a generic function that:

  1. Calls a handler factory to create a handler instance (with access to ctx, config, and logger)
  2. Calls a registration function that wires handler methods to routes
router.Group("/api/users").HandleWith(httpserver.With(NewHandler, func(r *httpserver.Router, h *Handler) {
r.GET("", httpserver.Bind(h.ListUsers))
r.POST("", httpserver.Bind(h.CreateUser))
r.GET("/:id", httpserver.Bind(h.GetUser))
r.DELETE("/:id", httpserver.Bind(h.DeleteUser))
}))

Handler factory

The handler factory must have this signature:

func NewHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*Handler, error)

Use it to initialize dependencies — database clients, external services, caches:

type Handler struct {
db *sql.DB
}

func NewHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*Handler, error) {
db, err := sql.Open("postgres", config.GetString("db_url"))
if err != nil {
return nil, err
}
return &Handler{db: db}, nil
}

Bind, BindR, BindN, and BindNR

The standard binding helpers differ by two concerns: whether the handler receives a bound input struct, and whether it receives the raw *http.Request.

The suffixes follow a simple pattern:

  • R means the raw *http.Request is passed to the handler.
  • N means there is no input struct to bind.
HelperHandler shapeUse when
Bind[I](ctx, *Input) (Response, error)The endpoint needs request data bound into an input struct. This is the default choice for most endpoints.
BindR[I](ctx, *http.Request, *Input) (Response, error)The endpoint needs both bound input and raw request access, for example headers, method, body metadata, or client IP resolution.
BindN(ctx) (Response, error)The endpoint does not need request input, for example health checks or static status endpoints.
BindNR(ctx, *http.Request) (Response, error)The endpoint does not need a bound input struct, but still needs raw request access.

Bind and BindR can bind request data from JSON bodies, query parameters, form data, URI parameters, headers, and other supported sources. BindN and BindNR skip request binding entirely because there is no input struct.

// With input
func (h *Handler) GetUser(ctx context.Context, input *GetUserInput) (httpserver.Response, error) {
// input is populated from the request
}

// With input and raw request access
func (h *Handler) Upload(ctx context.Context, req *http.Request, input *UploadInput) (httpserver.Response, error) {
contentType := req.Header.Get("Content-Type")
// input is populated from the request
}

// Without input
func (h *Handler) Health(ctx context.Context) (httpserver.Response, error) {
// no request data needed
}

// Without input, but with raw request access
func (h *Handler) Ping(ctx context.Context, req *http.Request) (httpserver.Response, error) {
userAgent := req.Header.Get("User-Agent")
// no input struct is bound
}

You can register multiple handler groups on the same router, each with its own constructor:

router.Group("/api/users").HandleWith(httpserver.With(NewUserHandler, func(r *httpserver.Router, h *UserHandler) {
r.GET("", httpserver.Bind(h.ListUsers))
r.POST("", httpserver.Bind(h.CreateUser))
}))

router.Group("/api/orders").HandleWith(httpserver.With(NewOrderHandler, func(r *httpserver.Router, h *OrderHandler) {
r.GET("", httpserver.Bind(h.ListOrders))
}))

Binding request data

Bind automatically populates input structs from the incoming request using struct tags.

URI parameters

Use the uri tag to bind path parameters. Define them in your route with :name syntax:

type UserIdInput struct {
Id int `uri:"id" binding:"required"`
}

router.GET("/users/:id", httpserver.Bind(h.GetUser))

Query string and form parameters

Use the form tag for query string parameters (GET) or form-encoded bodies (POST):

type ListUsersInput struct {
Role string `form:"role"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}

JSON request body

Use the json tag to bind from a JSON body. The content type application/json is auto-detected:

type CreateUserInput struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"omitempty,oneof=admin user guest"`
}

HTTP headers

Use the header tag to bind from request headers:

type AuthenticatedInput struct {
RequestId string `header:"X-Request-Id"`
UserAgent string `header:"User-Agent"`
}

Combining multiple sources

Combine tags to pull from multiple sources in a single input struct:

type ListFilesInput struct {
Database string `uri:"database"`
Table string `uri:"table"`
Partitions map[string]string `json:"partitions" form:"partitions"`
RequestId string `header:"X-Request-Id"`
}

URI parameters and headers are always bound when their tags are present. Body/query binding depends on the request's Content-Type header.

Auto-detection logic

Bind examines the input type's struct tags and the request Content-Type to determine how to bind:

The uri and header tags are inferred from the input type and are not selected by Content-Type.

Content-TypeTag used
application/jsonjson
application/xmlxml
application/x-yamlyaml
application/x-protobufprotobuf
application/x-msgpackmsgpack
application/x-www-form-urlencodedform
multipart/form-dataform
text/plainplain

Validation

The binding tag uses go-playground/validator. If binding or validation fails, the request is rejected with 400 Bad Request and a descriptive client error. Validation bind failures are also logged at warning level, not error level, with field, tag, invalid value, and parameter details:

TagDescription
requiredField must be present and non-zero
emailMust be a valid email
oneof=a b cMust be one of the listed values
gte=0Must be greater than or equal to 0
omitemptySkip validation if field is empty

BindR — access the raw request

If you need the raw *http.Request alongside your input struct:

func (h *Handler) Upload(ctx context.Context, req *http.Request, input *UploadInput) (httpserver.Response, error) {
contentType := req.Header.Get("Content-Type")
// ...
}

Resolve the client IP

When your handler needs the caller IP address, use ResolveClientIP with the raw request from BindR or BindNR:

func (h *Handler) GetProfile(ctx context.Context, req *http.Request, input *GetProfileInput) (httpserver.Response, error) {
clientIP, err := httpserver.ResolveClientIP(req)
if err != nil {
return nil, err
}

logger.Info(ctx, "profile requested from %s", clientIP)

// ...
}

ResolveClientIP checks X-Forwarded-For first, then X-Real-IP, and falls back to req.RemoteAddr. Use it when your service runs behind a trusted proxy or load balancer that sets these headers.

Sending responses

All handler methods return an httpserver.Response interface.

JSON responses

NewJsonResponse serializes any value as JSON:

return httpserver.NewJsonResponse(user), nil

Text responses

return httpserver.NewTextResponse("Hello, plain text!"), nil

Status-only responses

NewStatusResponse returns a response with only a status code — useful for DELETE, PUT, PATCH:

return httpserver.NewStatusResponse(204), nil

Custom status codes and headers

Use functional options to customize any response:

return httpserver.NewJsonResponse(
user,
httpserver.WithStatusCode(http.StatusCreated),
httpserver.WithHeader("X-Custom-Header", "my-value"),
), nil
OptionDescription
WithStatusCode(code int)Set the HTTP status code (default: 200)
WithHeader(key, value string)Add a single header
WithHeaders(headers http.Header)Merge multiple headers
WithBody(body []byte)Set the raw response body

Error handling

Return nil, error for unexpected errors. The error middleware returns 500 Internal Server Error with a sanitized {"err":"internal server error"} response, so internal details do not leak to clients:

func (h *Handler) Health(ctx context.Context) (httpserver.Response, error) {
if len(h.users) > 10000 {
return nil, fmt.Errorf("too many users")
}
return httpserver.NewJsonResponse(map[string]string{"status": "ok"}), nil
}

For client errors with a specific status code, use GetErrorHandler() — this returns a Response with nil error (a handled error):

func (h *Handler) GetUser(ctx context.Context, input *UserIdInput) (httpserver.Response, error) {
user, ok := h.users[input.Id]
if !ok {
return httpserver.GetErrorHandler()(http.StatusNotFound, errors.New("user not found")), nil
}
return httpserver.NewJsonResponse(user), nil
}

The key pattern: return a Response with nil error for handled errors, and nil, error for unexpected errors.

If middleware or lower-level code attaches an error to the Gin context and needs a specific status code, wrap it with NewErrorWithStatus:

ginCtx.Error(httpserver.NewErrorWithStatus(http.StatusBadRequest, err))

Complete example

main.go
main.go
package main

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/gosoline-project/httpserver"
"github.com/justtrackio/gosoline/pkg/cfg"
"github.com/justtrackio/gosoline/pkg/log"
)

func main() {
httpserver.RunDefaultServer(func(ctx context.Context, config cfg.Config, logger log.Logger, router *httpserver.Router) error {
router.Group("/api/users").HandleWith(httpserver.With(NewHandler, func(r *httpserver.Router, h *Handler) {
r.GET("", httpserver.Bind(h.ListUsers))
r.POST("", httpserver.Bind(h.CreateUser))
r.GET("/:id", httpserver.Bind(h.GetUser))
r.DELETE("/:id", httpserver.Bind(h.DeleteUser))
r.GET("/health", httpserver.BindN(h.Health))
}))

return nil
})
}

type ListUsersInput struct {
Role string `form:"role"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}

type CreateUserInput struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"omitempty,oneof=admin user guest"`
}

type UserIdInput struct {
Id int `uri:"id" binding:"required"`
}

type User struct {
Id int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
}

type Handler struct {
users map[int]*User
next int
}

func NewHandler(ctx context.Context, config cfg.Config, logger log.Logger) (*Handler, error) {
return &Handler{
users: map[int]*User{},
next: 1,
}, nil
}

func (h *Handler) ListUsers(ctx context.Context, input *ListUsersInput) (httpserver.Response, error) {
var result []*User
for _, u := range h.users {
if input.Role != "" && u.Role != input.Role {
continue
}
result = append(result, u)
if input.Limit > 0 && len(result) >= input.Limit {
break
}
}
return httpserver.NewJsonResponse(result), nil
}

func (h *Handler) CreateUser(ctx context.Context, input *CreateUserInput) (httpserver.Response, error) {
user := &User{
Id: h.next,
Name: input.Name,
Email: input.Email,
Role: input.Role,
}
h.users[user.Id] = user
h.next++

return httpserver.NewJsonResponse(user, httpserver.WithStatusCode(http.StatusCreated)), nil
}

func (h *Handler) GetUser(ctx context.Context, input *UserIdInput) (httpserver.Response, error) {
user, ok := h.users[input.Id]
if !ok {
return httpserver.GetErrorHandler()(http.StatusNotFound, errors.New("user not found")), nil
}
return httpserver.NewJsonResponse(user), nil
}

func (h *Handler) DeleteUser(ctx context.Context, input *UserIdInput) (httpserver.Response, error) {
delete(h.users, input.Id)
return httpserver.NewStatusResponse(http.StatusNoContent), nil
}

func (h *Handler) Health(ctx context.Context) (httpserver.Response, error) {
if len(h.users) > 10000 {
return nil, fmt.Errorf("too many users")
}
return httpserver.NewJsonResponse(map[string]string{"status": "ok"}), nil
}
config.dist.yml
config.dist.yml
app:
env: dev
name: build-service

httpserver:
default:
port: 8088

Test it:

# Create a user
curl -X POST http://localhost:8088/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com","role":"admin"}'
# {"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}

# List users
curl http://localhost:8088/api/users
# [{"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}]

# Get a user by ID
curl http://localhost:8088/api/users/1
# {"id":1,"name":"Alice","email":"alice@example.com","role":"admin"}

# Get a missing user
curl http://localhost:8088/api/users/99
# {"err":"user not found"}

# Delete a user
curl -X DELETE http://localhost:8088/api/users/1 -v
# HTTP/1.1 204 No Content

# Health check
curl http://localhost:8088/api/users/health
# {"status":"ok"}

What's next?