Módulo 10: Especialización de APIs: OpenAPI & Generación de Código (oapi-codegen)
Diseña el contrato primero y deja que el código se genere a partir de él.
Fase 3: Seguridad, APIs y tiempo real · Lección 13 de 20
🎯 Objetivos
- Describir una API REST en OpenAPI 3.0 (YAML) siguiendo el enfoque API-First.
- Generar tipos y servidor en Go con
oapi-codegen. - Implementar la interfaz generada reutilizando el servicio de dominio.
- Embeber Swagger UI en el binario con
go:embed.
1. ¿Por qué una API además de HTML?
Hasta ahora el servidor devuelve HTML para humanos con HTMX. Pero tu aplicación necesitará hablar con otros programas: la app de escritorio (Módulo 15), la móvil (Módulo 16), integraciones de terceros, scripts. Para ellos el formato adecuado es JSON sobre una API REST documentada.
Lo mejor: ambas interfaces comparten el mismo dominio (tarea.Servicio). Las dos son solo adaptadores distintos, tal como dibujamos en el Módulo 03:
HTML/HTMX (web) ─┐
├──▶ tarea.Servicio ──▶ Repositorio ──▶ PostgreSQL
JSON (api) ──┘
2. API-First y OpenAPI
API-First: se diseña y acuerda primero el contrato de la API y luego se implementa. Beneficios: el equipo de la app móvil puede empezar antes de que exista el servidor, la documentación nunca queda desactualizada (es la fuente), y se evitan discrepancias entre lo documentado y lo implementado.
OpenAPI (antes Swagger) es el estándar para describir APIs REST en un archivo YAML o JSON: rutas, métodos, parámetros, cuerpos, respuestas, errores, seguridad.
api/openapi.yaml:
openapi: 3.0.3
info:
title: API de Tareas
version: 1.0.0
description: API JSON del curso GoStack Open.
servers:
- url: http://localhost:8080/api/v1
paths:
/tareas:
get:
operationId: listarTareas
summary: Lista las tareas
parameters:
- name: estado
in: query
schema: { type: string, enum: [todas, pendientes, completadas], default: todas }
- name: limite
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
"200":
description: Lista de tareas
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Tarea" }
"401": { $ref: "#/components/responses/NoAutenticado" }
post:
operationId: crearTarea
summary: Crea una tarea
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NuevaTarea" }
responses:
"201":
description: Tarea creada
content:
application/json:
schema: { $ref: "#/components/schemas/Tarea" }
"422":
description: Datos inválidos
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"401": { $ref: "#/components/responses/NoAutenticado" }
/tareas/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
get:
operationId: obtenerTarea
summary: Obtiene una tarea
responses:
"200":
description: La tarea
content:
application/json:
schema: { $ref: "#/components/schemas/Tarea" }
"404":
description: No existe
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
patch:
operationId: actualizarTarea
summary: Modifica parcialmente una tarea
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ActualizarTarea" }
responses:
"200":
description: Tarea actualizada
content:
application/json:
schema: { $ref: "#/components/schemas/Tarea" }
"404":
description: No existe
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
delete:
operationId: eliminarTarea
summary: Elimina una tarea
responses:
"204": { description: Eliminada }
"404":
description: No existe
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Tarea:
type: object
required: [id, titulo, hecha, creada_en]
properties:
id: { type: integer, format: int64, example: 7 }
titulo: { type: string, example: Aprender Go }
hecha: { type: boolean }
creada_en: { type: string, format: date-time }
NuevaTarea:
type: object
required: [titulo]
properties:
titulo: { type: string, minLength: 1, maxLength: 120 }
ActualizarTarea:
type: object
properties:
titulo: { type: string, minLength: 1, maxLength: 120 }
hecha: { type: boolean }
Error:
type: object
required: [mensaje]
properties:
mensaje: { type: string }
campos:
type: object
additionalProperties: { type: string }
responses:
NoAutenticado:
description: Falta el token o es inválido
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
security:
- bearerAuth: []
Buenas prácticas de diseño REST:
- Recursos en plural y sustantivos:
/tareas,/tareas/{id}(no/obtenerTareas). - El método expresa la acción; el código de estado el resultado (Módulo -00).
- Versiona la API en la ruta (
/api/v1). - Errores con un formato uniforme.
- Cada operación con un
operationIdúnico: de él nace el nombre del método Go.
3. Generación de código con oapi-codegen
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
Configuración api/cfg.yaml:
package: api
output: internal/api/api.gen.go
generate:
models: true # structs Tarea, NuevaTarea, Error…
std-http-server: true # integración con net/http (ServeMux de Go 1.22+)
strict-server: true # interfaz con tipos de petición/respuesta fuertemente tipados
Y el generador, anotado en Go para ejecutarlo con go generate ./...:
// internal/api/generate.go
package api
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config ../../api/cfg.yaml ../../api/openapi.yaml
go generate ./internal/api
Qué obtienes en api.gen.go (resumido):
type Tarea struct {
CreadaEn time.Time `json:"creada_en"`
Hecha bool `json:"hecha"`
Id int64 `json:"id"`
Titulo string `json:"titulo"`
}
type StrictServerInterface interface {
ListarTareas(ctx context.Context, request ListarTareasRequestObject) (ListarTareasResponseObject, error)
CrearTarea(ctx context.Context, request CrearTareaRequestObject) (CrearTareaResponseObject, error)
ObtenerTarea(ctx context.Context, request ObtenerTareaRequestObject) (ObtenerTareaResponseObject, error)
ActualizarTarea(ctx context.Context, request ActualizarTareaRequestObject) (ActualizarTareaResponseObject, error)
EliminarTarea(ctx context.Context, request EliminarTareaRequestObject) (EliminarTareaResponseObject, error)
}
// Tipos de respuesta: uno por cada código documentado
type ListarTareas200JSONResponse []Tarea
type CrearTarea201JSONResponse Tarea
type ObtenerTarea404JSONResponse Error
type EliminarTarea204Response struct{}
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface
func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler // o HandlerWithOptions
La magia del modo estricto: si tu implementación intenta devolver un código que no está en el contrato, no compila. Si cambias el YAML y regeneras, el compilador te señala qué handlers hay que ajustar. Contrato y código no pueden divergir.
Los nombres exactos (
IdvsID, tipos de parámetros) dependen de la versión; revisaapi.gen.go. Los campos opcionales se generan como punteros (*string,*bool).
4. Implementar la interfaz
La implementación es fina: traduce entre los tipos de la API y el dominio.
package apihttp
import (
"context"
"errors"
"github.com/TU_USUARIO/tareas/internal/api"
"github.com/TU_USUARIO/tareas/internal/tarea"
)
type Servidor struct{ svc *tarea.Servicio }
func Nuevo(svc *tarea.Servicio) *Servidor { return &Servidor{svc: svc} }
var _ api.StrictServerInterface = (*Servidor)(nil)
func aAPI(t tarea.Tarea) api.Tarea {
return api.Tarea{Id: t.ID, Titulo: t.Titulo, Hecha: t.Hecha, CreadaEn: t.CreadaEn}
}
func (s *Servidor) ListarTareas(ctx context.Context, _ api.ListarTareasRequestObject) (api.ListarTareasResponseObject, error) {
ts, err := s.svc.Listar(ctx)
if err != nil {
return nil, err // el manejador de errores devolverá un 500
}
res := make(api.ListarTareas200JSONResponse, len(ts))
for i, t := range ts {
res[i] = aAPI(t)
}
return res, nil
}
func (s *Servidor) CrearTarea(ctx context.Context, req api.CrearTareaRequestObject) (api.CrearTareaResponseObject, error) {
t, err := s.svc.Crear(ctx, req.Body.Titulo)
if errors.Is(err, tarea.ErrTituloVacio) {
return api.CrearTarea422JSONResponse{Mensaje: err.Error()}, nil
}
if err != nil {
return nil, err
}
return api.CrearTarea201JSONResponse(aAPI(t)), nil
}
func (s *Servidor) ObtenerTarea(ctx context.Context, req api.ObtenerTareaRequestObject) (api.ObtenerTareaResponseObject, error) {
t, err := s.svc.Obtener(ctx, req.Id)
if errors.Is(err, tarea.ErrNoEncontrada) {
return api.ObtenerTarea404JSONResponse{Mensaje: "tarea no encontrada"}, nil
}
if err != nil {
return nil, err
}
return api.ObtenerTarea200JSONResponse(aAPI(t)), nil
}
func (s *Servidor) EliminarTarea(ctx context.Context, req api.EliminarTareaRequestObject) (api.EliminarTareaResponseObject, error) {
err := s.svc.Eliminar(ctx, req.Id)
if errors.Is(err, tarea.ErrNoEncontrada) {
return api.EliminarTarea404JSONResponse{Mensaje: "tarea no encontrada"}, nil
}
if err != nil {
return nil, err
}
return api.EliminarTarea204Response{}, nil
}
// ActualizarTarea sigue el mismo patrón (omitido por brevedad).
Montaje en el enrutador principal:
apiSrv := api.NewStrictHandler(apihttp.Nuevo(svc), nil)
api.HandlerWithOptions(apiSrv, api.StdHTTPServerOptions{
BaseRouter: mux,
BaseURL: "/api/v1",
})
Un detalle valioso: el mismo Servicio y el mismo repositorio sirven a HTML y a JSON. Las reglas de negocio no se duplican.
Validación de peticiones contra el contrato: el middleware github.com/oapi-codegen/nethttp-middleware (con kin-openapi) rechaza automáticamente peticiones que no cumplan el esquema (tipos, rangos, campos obligatorios), así que el YAML es también tu validador.
Autenticación de la API: usa un middleware que lea Authorization: Bearer … y verifique la JWT del Módulo 09; el HTML sigue usando cookies.
5. Swagger UI dentro del binario
Swagger UI es una interfaz web que lee tu openapi.yaml y permite explorar y probar la API desde el navegador. Queremos servirla desde el propio binario, sin depender de una CDN ni archivos externos, con go:embed.
- Obtén los archivos estáticos de Swagger UI (paquete npm
swagger-ui-dist). Necesitasswagger-ui.css,swagger-ui-bundle.jsyswagger-ui-standalone-preset.js; colócalos eninternal/docs/swagger/:
mkdir -p internal/docs/swagger && cd internal/docs/swagger
npm pack swagger-ui-dist && tar -xzf swagger-ui-dist-*.tgz
cp package/swagger-ui.css package/swagger-ui-bundle.js package/swagger-ui-standalone-preset.js .
rm -rf package swagger-ui-dist-*.tgz
- Copia o referencia tu contrato (
openapi.yaml) en el mismo paquete, y escribe unindex.html:
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>API de Tareas — Documentación</title>
<link rel="stylesheet" href="swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="swagger-ui-bundle.js"></script>
<script src="swagger-ui-standalone-preset.js"></script>
<script src="iniciar.js"></script>
</body>
</html>
iniciar.js (archivo aparte para no necesitar scripts inline con tu CSP):
window.ui = SwaggerUIBundle({
url: "openapi.yaml",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
});
- Incrústalo y sírvelo:
package docs
import (
"embed"
"io/fs"
"net/http"
)
//go:embed swagger/*
var contenido embed.FS
func Handler() http.Handler {
sub, _ := fs.Sub(contenido, "swagger")
return http.StripPrefix("/docs/", http.FileServer(http.FS(sub)))
}
mux.Handle("GET /docs/", docs.Handler())
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/docs/", http.StatusMovedPermanently)
})
Como la CSP del Módulo 09 es estricta, tendrás que relajarla solo para /docs/ (Swagger UI usa estilos inline), o servirla con una política propia.
Resultado: go build produce un único binario que contiene API, interfaz web y documentación interactiva en /docs/.
🧪 Práctica
Escribe openapi.yaml para /api/v1/tareas, genera el servidor, implementa la interfaz generada y sirve Swagger UI en /docs/ embebido en el binario.
Ver solución
# 1. Contrato y configuración (esta lección)
# 2. Generar
go generate ./internal/api && go build ./...
# 3. Implementar internal/apihttp (ListarTareas, CrearTarea, ObtenerTarea, ActualizarTarea, EliminarTarea)
# 4. Montar en main y en el mux; embeber Swagger UI
go run ./cmd/tareas
Pruebas:
curl -s localhost:8080/api/v1/tareas | jq
curl -s -X POST localhost:8080/api/v1/tareas \
-H 'Content-Type: application/json' -d '{"titulo":"Probar la API"}' | jq
curl -i localhost:8080/api/v1/tareas/9999 # 404 con cuerpo JSON {"mensaje": "..."}
Abre http://localhost:8080/docs/, pulsa Try it out en POST /tareas y verifica que obtienes 201. Comprueba además que go build genera un solo ejecutable que sirve la documentación desde cualquier carpeta.
Experimento: elimina "404" de obtenerTarea en el YAML, regenera y observa cómo el compilador rechaza tu handler que devuelve ObtenerTarea404JSONResponse.
⚠️ Errores comunes
- Editar
api.gen.goa mano (se sobrescribe en la siguiente generación). - Un contrato “decorativo” que nadie actualiza: si la API cambia, cambia primero el YAML.
- Meter reglas de negocio en la capa de la API en vez de en el servicio.
- Exponer en la API los tipos internos de base de datos.
- Documentación de Swagger sin autenticación en producción cuando la API es privada.
📌 Resumen
- API-First: el contrato OpenAPI (YAML) es la fuente de verdad.
oapi-codegengenera modelos y una interfaz estricta que el compilador obliga a cumplir.- La capa API es un adaptador delgado sobre el mismo dominio que usa el HTML.
- Swagger UI se incrusta con
go:embed: documentación interactiva dentro del binario.
✅ Autoevaluación
- ¿Qué significa API-First y qué ventaja aporta al trabajo en equipo?
- ¿Qué ocurre si cambias el YAML y regeneras? ¿Quién te avisa de lo que debes ajustar?
- ¿Por qué la implementación de la API reutiliza
tarea.Servicio? - ¿Qué papel cumple
operationId? - ¿Cómo logras que la documentación viaje dentro del binario?
- Puedo explicar con mis palabras cada objetivo de esta lección.
-
/docs/muestra Swagger UI y puedo crear una tarea desde ahí. - Sé qué parte me costó más y la repasaré.