MidПрактика7 min

REST API

Создание REST API с net/http (Go 1.22+), маршрутизация, JSON, валидация, пагинация

REST API на Go

С Go 1.22 стандартный net/http получил мощный маршрутизатор с поддержкой HTTP-методов и path-параметров. Для большинства проектов внешние роутеры больше не нужны.

Маршрутизация в Go 1.22+

Новый синтаксис

mux := http.NewServeMux()

// Method + path pattern
mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)

// Wildcard: matches remaining path
mux.HandleFunc("GET /files/{path...}", serveFiles)

// Exact match with trailing slash
mux.HandleFunc("GET /api/", apiIndex) // matches only /api/

Path-параметры

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // extract {id} from URL
    if id == "" {
        http.Error(w, "id is required", http.StatusBadRequest)
        return
    }

    // Use id...
}

Полный CRUD-пример: User Resource

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log/slog"
    "net/http"
    "sync"
    "time"
)

// User represents a user in the system.
type User struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

// CreateUserRequest is the request body for creating a user.
type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// Validate checks if the request is valid.
func (r CreateUserRequest) Validate() error {
    if r.Name == "" {
        return fmt.Errorf("name is required")
    }
    if r.Email == "" {
        return fmt.Errorf("email is required")
    }
    return nil
}

// UpdateUserRequest is the request body for updating a user.
type UpdateUserRequest struct {
    Name  *string `json:"name,omitempty"`
    Email *string `json:"email,omitempty"`
}

// ErrorResponse is the standard error response format.
type ErrorResponse struct {
    Error   string `json:"error"`
    Code    int    `json:"code"`
    Details string `json:"details,omitempty"`
}

// UserStore provides in-memory user storage.
type UserStore struct {
    mu    sync.RWMutex
    users map[string]*User
    seq   int
}

// NewUserStore creates an empty UserStore.
func NewUserStore() *UserStore {
    return &UserStore{users: make(map[string]*User)}
}

// UserHandler handles HTTP requests for users.
type UserHandler struct {
    store  *UserStore
    logger *slog.Logger
}

// NewUserHandler creates a UserHandler.
func NewUserHandler(store *UserStore, logger *slog.Logger) *UserHandler {
    return &UserHandler{store: store, logger: logger}
}

// RegisterRoutes registers all user routes.
func (h *UserHandler) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/users", h.List)
    mux.HandleFunc("POST /api/users", h.Create)
    mux.HandleFunc("GET /api/users/{id}", h.Get)
    mux.HandleFunc("PUT /api/users/{id}", h.Update)
    mux.HandleFunc("DELETE /api/users/{id}", h.Delete)
}

// List returns all users.
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
    h.store.mu.RLock()
    users := make([]*User, 0, len(h.store.users))
    for _, u := range h.store.users {
        users = append(users, u)
    }
    h.store.mu.RUnlock()

    writeJSON(w, http.StatusOK, users)
}

// Create creates a new user.
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON", err.Error())
        return
    }

    if err := req.Validate(); err != nil {
        writeError(w, http.StatusUnprocessableEntity, "validation failed", err.Error())
        return
    }

    h.store.mu.Lock()
    h.store.seq++
    id := fmt.Sprintf("user-%d", h.store.seq)
    now := time.Now()
    user := &User{
        ID:        id,
        Name:      req.Name,
        Email:     req.Email,
        CreatedAt: now,
        UpdatedAt: now,
    }
    h.store.users[id] = user
    h.store.mu.Unlock()

    h.logger.Info("user created", "id", id, "name", req.Name)

    writeJSON(w, http.StatusCreated, user)
}

// Get returns a single user by ID.
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    h.store.mu.RLock()
    user, ok := h.store.users[id]
    h.store.mu.RUnlock()

    if !ok {
        writeError(w, http.StatusNotFound, "user not found", "")
        return
    }

    writeJSON(w, http.StatusOK, user)
}

// Update partially updates a user.
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    var req UpdateUserRequest
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON", err.Error())
        return
    }

    h.store.mu.Lock()
    user, ok := h.store.users[id]
    if !ok {
        h.store.mu.Unlock()
        writeError(w, http.StatusNotFound, "user not found", "")
        return
    }

    if req.Name != nil {
        user.Name = *req.Name
    }
    if req.Email != nil {
        user.Email = *req.Email
    }
    user.UpdatedAt = time.Now()
    h.store.mu.Unlock()

    writeJSON(w, http.StatusOK, user)
}

// Delete removes a user by ID.
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    h.store.mu.Lock()
    _, ok := h.store.users[id]
    if ok {
        delete(h.store.users, id)
    }
    h.store.mu.Unlock()

    if !ok {
        writeError(w, http.StatusNotFound, "user not found", "")
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

Вспомогательные функции

// decodeJSON decodes JSON request body with size limit.
func decodeJSON(r *http.Request, v any) error {
    // Limit request body to 1MB
    r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // reject unknown fields

    if err := dec.Decode(v); err != nil {
        return fmt.Errorf("decoding JSON: %w", err)
    }

    // Ensure no extra data after JSON
    if dec.More() {
        return fmt.Errorf("request body must contain a single JSON object")
    }

    return nil
}

// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(v); err != nil {
        slog.Error("encoding JSON response", "err", err)
    }
}

// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg, details string) {
    writeJSON(w, status, ErrorResponse{
        Error:   msg,
        Code:    status,
        Details: details,
    })
}

Cursor-based пагинация

Offset-based пагинация (OFFSET 100 LIMIT 10) неэффективна при больших объёмах данных. Cursor-based пагинация стабильна и масштабируется.

// PageRequest represents cursor-based pagination parameters.
type PageRequest struct {
    Cursor string // opaque cursor from previous response
    Limit  int    // items per page (default 20, max 100)
}

// PageResponse wraps paginated results.
type PageResponse[T any] struct {
    Items      []T    `json:"items"`
    NextCursor string `json:"next_cursor,omitempty"`
    HasMore    bool   `json:"has_more"`
}

// parsePagination extracts pagination params from query string.
func parsePagination(r *http.Request) PageRequest {
    cursor := r.URL.Query().Get("cursor")

    limit := 20
    if l := r.URL.Query().Get("limit"); l != "" {
        if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
            limit = parsed
        }
    }

    return PageRequest{Cursor: cursor, Limit: limit}
}

// Example: paginated list of users
func (h *UserHandler) ListPaginated(w http.ResponseWriter, r *http.Request) {
    page := parsePagination(r)

    // In real code: cursor is base64-encoded last ID
    users, nextCursor, err := h.store.ListAfterCursor(r.Context(), page.Cursor, page.Limit+1)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "listing users", err.Error())
        return
    }

    hasMore := len(users) > page.Limit
    if hasMore {
        users = users[:page.Limit] // trim extra item
        nextCursor = users[len(users)-1].ID
    }

    writeJSON(w, http.StatusOK, PageResponse[*User]{
        Items:      users,
        NextCursor: nextCursor,
        HasMore:    hasMore,
    })
}

SQL для cursor-based пагинации:

-- First page
SELECT * FROM users ORDER BY id LIMIT 21;

-- Next page (cursor = last ID from previous page)
SELECT * FROM users WHERE id > $1 ORDER BY id LIMIT 21;

Middleware и запуск сервера

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    store := NewUserStore()
    handler := NewUserHandler(store, logger)

    mux := http.NewServeMux()
    handler.RegisterRoutes(mux)

    // Add middleware
    var h http.Handler = mux
    h = loggingMiddleware(logger)(h)
    h = recoveryMiddleware()(h)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      h,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Graceful shutdown
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    go func() {
        logger.Info("server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            logger.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

    <-ctx.Done()
    logger.Info("shutting down")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    srv.Shutdown(shutdownCtx)
}

chi router как альтернатива

Для более продвинутых возможностей (middleware groups, URL params, sub-routers) можно использовать go-chi/chi:

import "github.com/go-chi/chi/v5"

r := chi.NewRouter()

// Built-in middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))

// Route groups
r.Route("/api/v1", func(r chi.Router) {
    r.Use(authMiddleware) // applied to all /api/v1/* routes

    r.Route("/users", func(r chi.Router) {
        r.Get("/", listUsers)       // GET /api/v1/users
        r.Post("/", createUser)     // POST /api/v1/users

        r.Route("/{userID}", func(r chi.Router) {
            r.Get("/", getUser)     // GET /api/v1/users/{userID}
            r.Put("/", updateUser)  // PUT /api/v1/users/{userID}
            r.Delete("/", deleteUser)
        })
    })
})

// Extract path param
func getUser(w http.ResponseWriter, r *http.Request) {
    userID := chi.URLParam(r, "userID")
    // ...
}

Когда использовать chi:

  • Нужны middleware groups
  • Нужен sub-routing
  • Нужна совместимость с net/http (chi реализует http.Handler)

Когда достаточно net/http:

  • Простой API (< 20 эндпоинтов)
  • Нет сложных middleware-цепочек
  • Go 1.22+ (есть method-based routing)

Проверь себя

Как извлечь path-параметр из URL в Go 1.22+?

Почему cursor-based пагинация лучше offset-based?

Зачем использовать http.MaxBytesReader при декодировании JSON?

Какой формат маршрута правильный для Go 1.22+ net/http?