MFormations
Modern Go Engineering

Chapitre 8

08 - API avec Go

08 - API avec Go

Cours 08 : API avec Go

1. RESTful API Design

1.1 Principes REST

// Resources (noms, pas de verbes)
GET    /users          // Liste
POST   /users          // Créer
GET    /users/{id}     // Détail
PUT    /users/{id}     // Remplacer
PATCH  /users/{id}     // Partiel
DELETE /users/{id}     // Supprimer

// Relations
GET    /users/{id}/orders      // Commandes d'un user
GET    /users/{id}/orders/{oid} // Détail commande
POST   /users/{id}/orders      // Créer commande

// Actions (si nécessaire)
POST   /users/{id}/activate    // Action non-CRUD
POST   /orders/{id}/cancel     // Action non-CRUD

1.2 Chi Router avancé

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

func NewRouter(h *Handler, mw *Middleware) *chi.Mux {
    r := chi.NewRouter()

    // Global middleware
    r.Use(mw.Logger)
    r.Use(mw.Recoverer)
    r.Use(mw.RequestID)
    r.Use(mw.RealIP)
    r.Use(mw.CORS)
    r.Use(mw.RateLimit)

    // Health check
    r.Get("/health", h.Health)

    // API v1
    r.Route("/api/v1", func(r chi.Router) {
        // Public
        r.Post("/auth/login", h.Login)

        // Protected
        r.Group(func(r chi.Router) {
            r.Use(mw.Auth)
            r.Use(mw.RequirePermission("users:read"))

            r.Route("/users", func(r chi.Router) {
                r.Get("/", h.ListUsers)
                r.Post("/", h.CreateUser)
                r.Route("/{userID}", func(r chi.Router) {
                    r.Use(h.UserCtx)
                    r.Get("/", h.GetUser)
                    r.Put("/", h.UpdateUser)
                    r.Delete("/", h.DeleteUser)
                })
            })
        })
    })

    return r
}

1.3 Response helpers

type APIResponse struct {
    Data   any    `json:"data,omitempty"`
    Meta   *Meta  `json:"meta,omitempty"`
    Error  *APIError `json:"error,omitempty"`
}

type Meta struct {
    Page       int `json:"page"`
    PerPage    int `json:"per_page"`
    Total      int `json:"total"`
    TotalPages int `json:"total_pages"`
}

type APIError struct {
    Status  int    `json:"status"`
    Code    string `json:"code"`
    Message string `json:"message"`
}

func respond(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    
    resp := APIResponse{Data: data}
    json.NewEncoder(w).Encode(resp)
}

func respondPaginated(w http.ResponseWriter, data any, page, perPage, total int) {
    totalPages := (total + perPage - 1) / perPage
    w.Header().Set("Content-Type", "application/json")
    
    resp := APIResponse{
        Data: data,
        Meta: &Meta{
            Page:       page,
            PerPage:    perPage,
            Total:      total,
            TotalPages: totalPages,
        },
    }
    json.NewEncoder(w).Encode(resp)
}

func respondError(w http.ResponseWriter, status int, code, message string) {
    resp := APIResponse{
        Error: &APIError{
            Status:  status,
            Code:    code,
            Message: message,
        },
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(resp)
}

2. OpenAPI / Swagger

2.1 ogen (Code Generation)

# api/openapi.yaml
openapi: "3.0.3"
info:
  title: User API
  version: "1.0"
paths:
  /users:
    get:
      operationId: listUsers
      parameters:
        - name: page
          in: query
          schema:
            type: integer
        - name: size
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: List of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
    post:
      operationId: createUser
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string
    CreateUserRequest:
      type: object
      required: [name, email]
      properties:
        name:
          type: string
          minLength: 2
        email:
          type: string
          format: email
# Génération
ogen --target api --package api api/openapi.yaml
// Utilisation du code généré
type UserHandler struct {
    userService *usecase.UserService
}

func (h *UserHandler) ListUsers(ctx context.Context, params api.ListUsersParams) ([]api.User, error) {
    users, err := h.userService.List(ctx, params.Page, params.Size)
    if err != nil {
        return nil, err
    }
    
    result := make([]api.User, len(users))
    for i, u := range users {
        result[i] = api.User{
            ID:    u.ID,
            Name:  u.Name,
            Email: u.Email,
        }
    }
    return result, nil
}

2.2 swaggo (Annotations)

// @Summary Create a new user
// @Description Create a new user with the input payload
// @Tags users
// @Accept json
// @Produce json
// @Param user body CreateUserRequest true "User data"
// @Success 201 {object} User
// @Failure 400 {object} APIError
// @Router /users [post]
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    // ...
}
swag init -g cmd/server/main.go

3. Validation (go-playground/validator)

3.1 Struct validation

import "github.com/go-playground/validator/v10"

var validate = validator.New()

type CreateUserRequest struct {
    Name     string `json:"name"     validate:"required,min=2,max=100"`
    Email    string `json:"email"    validate:"required,email"`
    Age      int    `json:"age"      validate:"required,gte=18,lte=120"`
    Password string `json:"password" validate:"required,min=8,containsany=!@#$%"`
    Role     string `json:"role"     validate:"oneof=admin user moderator"`
}

type UpdateUserRequest struct {
    Name  *string `json:"name,omitempty"  validate:"omitempty,min=2,max=100"`
    Email *string `json:"email,omitempty" validate:"omitempty,email"`
    Age   *int    `json:"age,omitempty"   validate:"omitempty,gte=18,lte=120"`
}

func validateRequest(r *http.Request, req any) error {
    if err := json.NewDecoder(r.Body).Decode(req); err != nil {
        return fmt.Errorf("invalid JSON: %w", err)
    }
    
    if err := validate.Struct(req); err != nil {
        var ve validator.ValidationErrors
        if errors.As(err, &ve) {
            return NewValidationError(ve)
        }
        return err
    }
    
    return nil
}

type ValidationError struct {
    Fields []FieldError `json:"fields"`
}

type FieldError struct {
    Field   string `json:"field"`
    Tag     string `json:"tag"`
    Value   any    `json:"value"`
    Message string `json:"message"`
}

func NewValidationError(ve validator.ValidationErrors) *ValidationError {
    fields := make([]FieldError, len(ve))
    for i, fe := range ve {
        fields[i] = FieldError{
            Field: fe.Field(),
            Tag:   fe.Tag(),
            Value: fe.Value(),
            Message: fmt.Sprintf("%s is %s", fe.Field(), fe.Tag()),
        }
    }
    return &ValidationError{Fields: fields}
}

4. Versioning

4.1 URL Versioning

r.Route("/api/v1", func(r chi.Router) {
    r.Get("/users", h.V1.ListUsers)
})

r.Route("/api/v2", func(r chi.Router) {
    r.Get("/users", h.V2.ListUsers)
})

4.2 Header Versioning

func VersionMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        version := r.Header.Get("Accept-Version")
        
        ctx := context.WithValue(r.Context(), "api_version", version)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func VersionedHandler(handlers map[string]http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        version := r.Context().Value("api_version").(string)
        if handler, ok := handlers[version]; ok {
            handler(w, r)
            return
        }
        // Default to latest
        handlers["latest"](w, r)
    }
}

5. Pagination, Filtering, Sorting

5.1 Request parameters

type ListQuery struct {
    Page     int      `json:"page"`
    PerPage  int      `json:"per_page"`
    Sort     string   `json:"sort"`
    Order    string   `json:"order"`
    Search   string   `json:"search"`
    Filters  map[string]string `json:"filters"`
}

func parseListQuery(r *http.Request) (*ListQuery, error) {
    q := r.URL.Query()
    
    page, _ := strconv.Atoi(q.Get("page"))
    if page < 1 {
        page = 1
    }
    
    perPage, _ := strconv.Atoi(q.Get("per_page"))
    if perPage < 1 || perPage > 100 {
        perPage = 20
    }
    
    sort := q.Get("sort")
    if sort == "" {
        sort = "created_at"
    }
    
    order := q.Get("order")
    if order != "asc" && order != "desc" {
        order = "desc"
    }
    
    filters := make(map[string]string)
    for key, values := range q {
        if strings.HasPrefix(key, "filter[") && len(values) > 0 {
            field := strings.TrimPrefix(strings.TrimSuffix(key, "]"), "filter[")
            filters[field] = values[0]
        }
    }
    
    return &ListQuery{
        Page:    page,
        PerPage: perPage,
        Sort:    sort,
        Order:   order,
        Search:  q.Get("search"),
        Filters: filters,
    }, nil
}

5.2 Database query building

func buildListQuery(baseQuery string, q *ListQuery, allowedSorts []string) (string, []any) {
    var conditions []string
    var args []any
    argIdx := 1

    // Search
    if q.Search != "" {
        conditions = append(conditions, fmt.Sprintf(
            "(name ILIKE $%d OR email ILIKE $%d)", argIdx, argIdx))
        args = append(args, "%"+q.Search+"%")
        argIdx++
    }

    // Filters
    for field, value := range q.Filters {
        conditions = append(conditions, fmt.Sprintf("%s = $%d", field, argIdx))
        args = append(args, value)
        argIdx++
    }

    // WHERE clause
    if len(conditions) > 0 {
        baseQuery += " WHERE " + strings.Join(conditions, " AND ")
    }

    // Sort
    sortValid := false
    for _, s := range allowedSorts {
        if q.Sort == s {
            sortValid = true
            break
        }
    }
    if sortValid {
        baseQuery += fmt.Sprintf(" ORDER BY %s %s", q.Sort, q.Order)
    }

    // Pagination
    offset := (q.Page - 1) * q.PerPage
    baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
    args = append(args, q.PerPage, offset)

    return baseQuery, args
}

6. HATEOAS

type Resource[T any] struct {
    Data   T          `json:"data"`
    Links  []Link     `json:"links"`
}

type Link struct {
    Rel    string `json:"rel"`
    Href   string `json:"href"`
    Method string `json:"method"`
}

type PaginatedResource[T any] struct {
    Data   []T         `json:"data"`
    Links  []Link      `json:"links"`
    Meta   *PageMeta   `json:"meta"`
}

type PageMeta struct {
    CurrentPage int `json:"current_page"`
    PerPage     int `json:"per_page"`
    Total       int `json:"total"`
    TotalPages  int `json:"total_pages"`
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    userID := chi.URLParam(r, "userID")
    user, _ := h.userService.GetByID(r.Context(), userID)
    
    resource := Resource[*domain.User]{
        Data: user,
        Links: []Link{
            {Rel: "self", Href: fmt.Sprintf("/api/v1/users/%s", user.ID), Method: "GET"},
            {Rel: "update", Href: fmt.Sprintf("/api/v1/users/%s", user.ID), Method: "PUT"},
            {Rel: "delete", Href: fmt.Sprintf("/api/v1/users/%s", user.ID), Method: "DELETE"},
            {Rel: "orders", Href: fmt.Sprintf("/api/v1/users/%s/orders", user.ID), Method: "GET"},
        },
    }
    
    respond(w, http.StatusOK, resource)
}

7. Error Handling (Problem JSON - RFC 9457)

type Problem struct {
    Type     string `json:"type"`
    Title    string `json:"title"`
    Status   int    `json:"status"`
    Detail   string `json:"detail"`
    Instance string `json:"instance,omitempty"`
}

func NewProblem(status int, title, detail string) *Problem {
    return &Problem{
        Type:   fmt.Sprintf("https://api.example.com/problems/%d", status),
        Title:  title,
        Status: status,
        Detail: detail,
    }
}

func ProblemHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/problem+json")
        next.ServeHTTP(w, r)
    })
}

func respondProblem(w http.ResponseWriter, status int, detail string) {
    problem := NewProblem(status, http.StatusText(status), detail)
    problem.Instance = r.URL.Path
    
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(problem)
}

8. Middleware Chain

type Middleware struct {
    logger    *slog.Logger
    rateLimit *RateLimiter
    auth      AuthService
    cors      CORSService
}

func (mw *Middleware) Chain(handler http.HandlerFunc, middlewares ...func(http.Handler) http.Handler) http.Handler {
    var h http.Handler = http.HandlerFunc(handler)
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

func (mw *Middleware) ProtectedHandler(handler http.HandlerFunc) http.Handler {
    return mw.Chain(handler,
        mw.RateLimit,
        mw.Auth,
        mw.Logger,
        mw.Recoverer,
    )
}

9. CORS

func CORSMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
    origins := make(map[string]bool)
    for _, o := range allowedOrigins {
        origins[o] = true
    }

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")
            
            if origins[origin] || len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
                w.Header().Set("Access-Control-Allow-Origin", origin)
            }
            
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
            w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-API-Key")
            w.Header().Set("Access-Control-Allow-Credentials", "true")
            w.Header().Set("Access-Control-Max-Age", "300")

            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

Résumé

  • RESTful design : resources, méthodes HTTP standard
  • Chi : routing avancé, groups, middleware
  • OpenAPI : ogen (code gen), swaggo (annotations)
  • Validation : go-playground/validator
  • Versioning : URL (/v1/, /v2/) ou header
  • Pagination : page/per_page, cursors
  • HATEOAS : liens dans les réponses
  • Error handling : Problem JSON (RFC 9457)
  • CORS : configuration par origine
  • Rate limiting : par IP/API key