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