Qué cambió — paso 13
Qué cambió · paso-12 → paso-13

Cambios de la lección 13

Todo lo que cambia en tienda/pasos/paso-13 respecto al paso anterior. Vuelve a la lección: 13. Arquitectura hexagonal (sin complicarla).

10 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

cmd/api/main.go modificado · +11 −3
@@ -1,7 +1,10 @@
 // Command api es api-pedidos: la API REST de pedidos, protegida con access
 // tokens de Keycloak.
 //
-// Uso (desde tienda/pasos/paso-05):
+// Desde la lección 13 sigue la arquitectura hexagonal: este main es el único
+// sitio que conoce todas las piezas y las conecta (dominio + adaptadores).
+//
+// Uso (desde tienda/pasos/paso-13):
 //
 //	go run ./cmd/api
 package main
@@ -14,7 +17,9 @@
 	"strings"
 	"time"
 
-	"tienda/internal/api"
+	"tienda/internal/adaptadores/keycloak"
+	"tienda/internal/adaptadores/memoria"
+	"tienda/internal/adaptadores/rest"
 	"tienda/internal/apiauth"
 	"tienda/internal/pedidos"
 )
@@ -32,8 +37,11 @@
 		log.Fatal(err) // ¿Keycloak está arrancado?
 	}
 
+	// Conectar las piezas: repositorio (salida) → dominio → REST (entrada),
+	// con Keycloak como adaptador de identidad.
+	svc := pedidos.NewService(memoria.NewWithSamples())
 	mux := http.NewServeMux()
-	api.Register(mux, verifier, pedidos.NewStore())
+	rest.Register(mux, svc, keycloak.Identity{Verifier: verifier})
 
 	srv := &http.Server{
 		Addr:              addr,
internal/adaptadores/keycloak/keycloak.go nuevo · +54 −0
@@ -0,0 +1,54 @@
+// Package keycloak es el adaptador de identidad de api-pedidos: valida el
+// access token (con apiauth) y traduce lo que dice Keycloak (roles de realm,
+// roles de client, scopes) a lo que entiende el dominio (pedidos.Actor).
+//
+// Es el único sitio de api-pedidos que conoce los nombres de los roles. Si
+// mañana el rol «admin» se llama «tienda-admin», o los permisos vienen de
+// otro proveedor, solo cambia este archivo.
+package keycloak
+
+import (
+	"net/http"
+
+	"tienda/internal/apiauth"
+	"tienda/internal/pedidos"
+)
+
+// Identity implementa rest.Identity con access tokens de Keycloak.
+type Identity struct {
+	Verifier *apiauth.Verifier
+}
+
+// Middleware exige un access token válido (401 si no lo hay).
+func (i Identity) Middleware(next http.Handler) http.Handler { return i.Verifier.Middleware(next) }
+
+// RequireScope exige un scope en el token (403 insufficient_scope si falta).
+func (Identity) RequireScope(scope string) func(http.Handler) http.Handler {
+	return apiauth.RequireScope(scope)
+}
+
+// Actor devuelve quién llama, ya en términos del dominio.
+func (Identity) Actor(r *http.Request) pedidos.Actor {
+	p := apiauth.FromContext(r.Context())
+	if p == nil {
+		return pedidos.Actor{}
+	}
+	return ActorFrom(p)
+}
+
+// ActorFrom traduce un token verificado a un Actor del dominio.
+func ActorFrom(p *apiauth.Principal) pedidos.Actor {
+	a := pedidos.Actor{ID: p.Subject, Name: p.Username}
+	if p.HasRole("cliente") || p.HasRole("admin") {
+		a.Permissions = append(a.Permissions, pedidos.PermBuy)
+	}
+	if p.HasRole("admin") {
+		a.Permissions = append(a.Permissions, pedidos.PermManage)
+	}
+	for _, r := range p.APIRoles { // roles de client de api-pedidos (servicios)
+		if r == "facturar" {
+			a.Permissions = append(a.Permissions, pedidos.PermInvoice)
+		}
+	}
+	return a
+}
internal/adaptadores/memoria/memoria.go nuevo · +91 −0
@@ -0,0 +1,91 @@
+// Package memoria es un adaptador de salida: guarda los pedidos en memoria.
+// Implementa pedidos.Repository; cambiarlo por PostgreSQL no tocaría el dominio.
+package memoria
+
+import (
+	"context"
+	"sort"
+	"sync"
+	"time"
+
+	"tienda/internal/pedidos"
+)
+
+// IDs fijos de los usuarios del realm (ver infra/realm/tienda-realm.json),
+// para los pedidos de ejemplo.
+const (
+	AnaID    = "00000000-0000-4000-8000-0000000000a1"
+	CarlosID = "00000000-0000-4000-8000-0000000000c1"
+)
+
+// Repository guarda los pedidos en un mapa, seguro para uso concurrente.
+type Repository struct {
+	mu     sync.Mutex
+	orders map[int]pedidos.Order
+	nextID int
+}
+
+// New crea un repositorio vacío.
+func New() *Repository {
+	return &Repository{orders: make(map[int]pedidos.Order), nextID: 1001}
+}
+
+// NewWithSamples crea un repositorio con los pedidos de ejemplo de ana y carlos.
+func NewWithSamples() *Repository {
+	day := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
+	r := New()
+	for _, o := range []pedidos.Order{
+		{Owner: AnaID, Items: "Gopher de peluche ×1, Pegatinas OIDC ×2", Total: 29.88, Status: "Enviado", CreatedAt: day},
+		{Owner: AnaID, Items: "Taza «go fmt» ×1", Total: 9.50, Status: "Pendiente", CreatedAt: day.Add(48 * time.Hour)},
+		{Owner: CarlosID, Items: "Camiseta Keycloak ×2", Total: 30.00, Status: "Entregado", CreatedAt: day.Add(24 * time.Hour)},
+	} {
+		_, _ = r.Add(context.Background(), o)
+	}
+	return r
+}
+
+// Get devuelve un pedido o pedidos.ErrNotFound.
+func (r *Repository) Get(_ context.Context, id int) (pedidos.Order, error) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	o, ok := r.orders[id]
+	if !ok {
+		return pedidos.Order{}, pedidos.ErrNotFound
+	}
+	return o, nil
+}
+
+// List devuelve los pedidos que cumplen el filtro, ordenados por número.
+func (r *Repository) List(_ context.Context, f pedidos.Filter) ([]pedidos.Order, error) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	out := []pedidos.Order{}
+	for _, o := range r.orders {
+		if (f.Owner == "" || o.Owner == f.Owner) && (f.Status == "" || o.Status == f.Status) {
+			out = append(out, o)
+		}
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+	return out, nil
+}
+
+// Add guarda un pedido nuevo con el siguiente número libre.
+func (r *Repository) Add(_ context.Context, o pedidos.Order) (pedidos.Order, error) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	o.ID = r.nextID
+	r.nextID++
+	r.orders[o.ID] = o
+	return o, nil
+}
+
+// Update guarda los cambios de un pedido existente.
+func (r *Repository) Update(_ context.Context, o pedidos.Order) error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if _, ok := r.orders[o.ID]; !ok {
+		return pedidos.ErrNotFound
+	}
+	r.orders[o.ID] = o
+	return nil
+}
internal/adaptadores/rest/rest.go nuevo · +176 −0
@@ -0,0 +1,176 @@
+// Package rest es el adaptador de entrada HTTP de api-pedidos: traduce
+// peticiones REST a llamadas al dominio (pedidos.Service) y sus resultados y
+// errores a JSON y códigos de estado.
+package rest
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"strconv"
+	"time"
+
+	"tienda/internal/jsonhttp"
+	"tienda/internal/pedidos"
+)
+
+// Identity es lo que este adaptador necesita saber de quien llama. Es un
+// puerto: lo implementa el adaptador de Keycloak (y, en los tests, un doble).
+type Identity interface {
+	// Middleware exige que la petición venga autenticada (401 si no).
+	Middleware(next http.Handler) http.Handler
+	// RequireScope exige que la aplicación cliente tenga ese scope (403).
+	RequireScope(scope string) func(http.Handler) http.Handler
+	// Actor devuelve quién llama, en términos del dominio.
+	Actor(r *http.Request) pedidos.Actor
+}
+
+type handlers struct {
+	svc *pedidos.Service
+	id  Identity
+}
+
+// Register añade las rutas de la API. Aquí solo se exige un token válido y,
+// donde toca, el scope (lo que la APLICACIÓN puede hacer en nombre del
+// usuario). Lo que la PERSONA puede hacer lo decide el dominio.
+func Register(mux *http.ServeMux, svc *pedidos.Service, id Identity) {
+	h := &handlers{svc: svc, id: id}
+	escribir := id.RequireScope("pedidos:escribir")
+	auth := func(f http.HandlerFunc) http.Handler { return id.Middleware(f) }
+
+	mux.Handle("GET /pedidos", auth(h.list))
+	mux.Handle("GET /pedidos/{id}", auth(h.get))
+	mux.Handle("POST /pedidos", id.Middleware(escribir(http.HandlerFunc(h.create))))
+	mux.Handle("GET /admin/pedidos", auth(h.listAll))
+	mux.Handle("PATCH /admin/pedidos/{id}", id.Middleware(escribir(http.HandlerFunc(h.setStatus))))
+	mux.Handle("GET /facturacion/pedidos", auth(h.listByStatus))
+}
+
+// orderJSON es la forma de un pedido en la API. El dominio no lleva etiquetas
+// JSON: el formato de la API es cosa de este adaptador.
+type orderJSON struct {
+	ID        int       `json:"id"`
+	Owner     string    `json:"owner"`
+	Items     string    `json:"items"`
+	Total     float64   `json:"total"`
+	Status    string    `json:"status"`
+	CreatedAt time.Time `json:"created_at"`
+}
+
+func toJSON(o pedidos.Order) orderJSON {
+	return orderJSON{o.ID, o.Owner, o.Items, o.Total, o.Status, o.CreatedAt}
+}
+
+func listJSON(os []pedidos.Order) map[string]any {
+	out := make([]orderJSON, len(os))
+	for i, o := range os {
+		out[i] = toJSON(o)
+	}
+	return map[string]any{"pedidos": out}
+}
+
+func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
+	os, err := h.svc.Mine(r.Context(), h.id.Actor(r))
+	if err != nil {
+		writeError(w, err)
+		return
+	}
+	jsonhttp.Write(w, http.StatusOK, listJSON(os))
+}
+
+func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
+	id, ok := pathID(w, r)
+	if !ok {
+		return
+	}
+	o, err := h.svc.Get(r.Context(), h.id.Actor(r), id)
+	if err != nil {
+		writeError(w, err)
+		return
+	}
+	jsonhttp.Write(w, http.StatusOK, toJSON(o))
+}
+
+func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
+	var body struct {
+		Producto string `json:"producto"`
+		Cantidad int    `json:"cantidad"`
+	}
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
+		return
+	}
+	o, err := h.svc.Create(r.Context(), h.id.Actor(r), body.Producto, body.Cantidad)
+	if err != nil {
+		writeError(w, err)
+		return
+	}
+	jsonhttp.Write(w, http.StatusCreated, toJSON(o))
+}
+
+func (h *handlers) listAll(w http.ResponseWriter, r *http.Request) {
+	os, err := h.svc.All(r.Context(), h.id.Actor(r))
+	if err != nil {
+		writeError(w, err)
+		return
+	}
+	jsonhttp.Write(w, http.StatusOK, listJSON(os))
+}
+
+// listByStatus lo usa facturacion: ?status=Entregado por defecto.
+func (h *handlers) listByStatus(w http.ResponseWriter, r *http.Request) {
+	status := r.URL.Query().Get("status")
+	if status == "" {
+		status = "Entregado"
+	}
+	os, err := h.svc.ToInvoice(r.Context(), h.id.Actor(r), status)
+	if err != nil {
+		writeError(w, err)
+		return
+	}
+	jsonhttp.Write(w, http.StatusOK, listJSON(os))
+}
+
+func (h *handlers) setStatus(w http.ResponseWriter, r *http.Request) {
+	id, ok := pathID(w, r)
+	if !ok {
+		return
+	}
+	var body struct {
+		Status string `json:"status"`
+	}
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
+		return
+	}
+	o, err := h.svc.SetStatus(r.Context(), h.id.Actor(r), id, body.Status)
+	if err != nil {
+		writeError(w, err)
+		return
+	}
+	jsonhttp.Write(w, http.StatusOK, toJSON(o))
+}
+
+// writeError traduce los errores del dominio a respuestas HTTP.
+func writeError(w http.ResponseWriter, err error) {
+	switch {
+	case errors.Is(err, pedidos.ErrNotFound):
+		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
+	case errors.Is(err, pedidos.ErrForbidden):
+		jsonhttp.Error(w, http.StatusForbidden, "forbidden", "no tienes permiso para esto")
+	case errors.Is(err, pedidos.ErrInvalid):
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
+	default:
+		jsonhttp.Error(w, http.StatusInternalServerError, "server_error", "error interno")
+	}
+}
+
+// pathID lee {id} de la ruta; si no es un número, responde 400.
+func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
+	id, err := strconv.Atoi(r.PathValue("id"))
+	if err != nil {
+		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "el id debe ser un número")
+		return 0, false
+	}
+	return id, true
+}
internal/api/api.go eliminado · +0 −135
@@ -1,135 +0,0 @@
-// Package api contiene los handlers HTTP de api-pedidos.
-package api
-
-import (
-	"encoding/json"
-	"errors"
-	"net/http"
-	"strconv"
-
-	"tienda/internal/apiauth"
-	"tienda/internal/jsonhttp"
-	"tienda/internal/pedidos"
-)
-
-type handlers struct {
-	store *pedidos.Store
-}
-
-// Register añade las rutas de la API. Cada ruta declara, de un vistazo,
-// qué hace falta para llamarla: token válido + roles + scope.
-func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
-	h := &handlers{store: store}
-	cliente := apiauth.RequireRole("cliente", "admin")
-	admin := apiauth.RequireRole("admin")
-	escribir := apiauth.RequireScope("pedidos:escribir")
-
-	mux.Handle("GET /pedidos", protect(v, h.list, cliente))
-	mux.Handle("GET /pedidos/{id}", protect(v, h.get, cliente))
-	mux.Handle("POST /pedidos", protect(v, h.create, cliente, escribir))
-	mux.Handle("GET /admin/pedidos", protect(v, h.listAll, admin))
-	mux.Handle("PATCH /admin/pedidos/{id}", protect(v, h.setStatus, admin, escribir))
-
-	// Para servicios (Client Credentials): rol de client de api-pedidos.
-	mux.Handle("GET /facturacion/pedidos", protect(v, h.listByStatus, apiauth.RequireAPIRole("facturar")))
-}
-
-// protect encadena: token válido (v.Middleware) → cada comprobación → handler.
-func protect(v *apiauth.Verifier, h http.HandlerFunc, checks ...func(http.Handler) http.Handler) http.Handler {
-	var next http.Handler = h
-	for i := len(checks) - 1; i >= 0; i-- {
-		next = checks[i](next)
-	}
-	return v.Middleware(next)
-}
-
-// list devuelve los pedidos de quien llama, identificado por el «sub» del token.
-func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
-	p := apiauth.FromContext(r.Context())
-	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.ByOwner(p.Subject)})
-}
-
-// get devuelve un pedido si pertenece a quien llama (o si es admin). Si es de
-// otro usuario respondemos 404, no 403: así no revelamos qué pedidos existen.
-func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
-	p := apiauth.FromContext(r.Context())
-	id, ok := pathID(w, r)
-	if !ok {
-		return
-	}
-	o, found := h.store.Get(id)
-	if !found || (o.Owner != p.Subject && !p.HasRole("admin")) {
-		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
-		return
-	}
-	jsonhttp.Write(w, http.StatusOK, o)
-}
-
-// create registra un pedido a nombre de quien llama. El dueño sale del token
-// y el precio del catálogo: el cuerpo solo dice qué y cuánto.
-func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
-	p := apiauth.FromContext(r.Context())
-	var body struct {
-		Producto string `json:"producto"`
-		Cantidad int    `json:"cantidad"`
-	}
-	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
-		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
-		return
-	}
-	o, err := h.store.Create(p.Subject, body.Producto, body.Cantidad)
-	if err != nil {
-		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
-		return
-	}
-	jsonhttp.Write(w, http.StatusCreated, o)
-}
-
-// listAll devuelve todos los pedidos (solo admin).
-func (h *handlers) listAll(w http.ResponseWriter, r *http.Request) {
-	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.All()})
-}
-
-// listByStatus devuelve los pedidos de todos los clientes en un estado
-// (?status=Entregado por defecto). Lo usa el servicio de facturación.
-func (h *handlers) listByStatus(w http.ResponseWriter, r *http.Request) {
-	status := r.URL.Query().Get("status")
-	if status == "" {
-		status = "Entregado"
-	}
-	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.ByStatus(status)})
-}
-
-// setStatus cambia el estado de un pedido (solo admin con pedidos:escribir).
-func (h *handlers) setStatus(w http.ResponseWriter, r *http.Request) {
-	id, ok := pathID(w, r)
-	if !ok {
-		return
-	}
-	var body struct {
-		Status string `json:"status"`
-	}
-	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
-		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
-		return
-	}
-	o, err := h.store.SetStatus(id, body.Status)
-	switch {
-	case errors.Is(err, pedidos.ErrNotFound):
-		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
-	case err != nil:
-		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
-	default:
-		jsonhttp.Write(w, http.StatusOK, o)
-	}
-}
-
-// pathID lee {id} de la ruta; si no es un número, responde 400.
-func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
-	id, err := strconv.Atoi(r.PathValue("id"))
-	if err != nil {
-		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "el id debe ser un número")
-		return 0, false
-	}
-	return id, true
-}
internal/pedidos/actor.go nuevo · +24 −0
@@ -0,0 +1,24 @@
+package pedidos
+
+import "slices"
+
+// Permission es algo que el dominio permite hacer. Son conceptos de la
+// tienda, no de Keycloak: el adaptador de identidad decide qué roles o
+// claims del token dan cada permiso.
+type Permission string
+
+const (
+	PermBuy     Permission = "comprar"   // ver y crear pedidos propios
+	PermManage  Permission = "gestionar" // ver todos los pedidos y cambiar su estado
+	PermInvoice Permission = "facturar"  // listar pedidos de todos para facturarlos
+)
+
+// Actor es quien pide algo al dominio: una persona o un servicio.
+type Actor struct {
+	ID          string // identificador estable (el «sub»)
+	Name        string
+	Permissions []Permission
+}
+
+// Can indica si el actor tiene el permiso p.
+func (a Actor) Can(p Permission) bool { return slices.Contains(a.Permissions, p) }
internal/pedidos/pedido.go nuevo · +30 −0
@@ -0,0 +1,30 @@
+// Package pedidos es el dominio de api-pedidos: qué es un pedido, qué se puede
+// hacer con él y quién puede hacerlo. No sabe nada de HTTP, de JSON ni de
+// Keycloak: eso lo ponen los adaptadores (internal/adaptadores).
+package pedidos
+
+import (
+	"errors"
+	"time"
+)
+
+// Order es un pedido.
+type Order struct {
+	ID        int
+	Owner     string // quién lo hizo: el ID del Actor (el «sub» del token)
+	Items     string
+	Total     float64
+	Status    string
+	CreatedAt time.Time
+}
+
+// Statuses son los estados válidos de un pedido.
+var Statuses = []string{"Pendiente", "Enviado", "Entregado", "Cancelado"}
+
+// Errores del dominio. Los adaptadores deciden cómo contarlos (la API REST
+// los convierte en 404, 403 o 400).
+var (
+	ErrNotFound  = errors.New("pedido no encontrado")
+	ErrForbidden = errors.New("no tienes permiso")
+	ErrInvalid   = errors.New("datos inválidos")
+)
internal/pedidos/pedidos.go eliminado · +0 −133
@@ -1,133 +0,0 @@
-// Package pedidos es el dominio de api-pedidos: los pedidos y un almacén en
-// memoria con datos de ejemplo.
-package pedidos
-
-import (
-	"errors"
-	"fmt"
-	"slices"
-	"sort"
-	"sync"
-	"time"
-)
-
-// IDs fijos de los usuarios del realm (ver infra/realm/tienda-realm.json).
-// Los pedidos se asocian al «sub» del token, nunca al nombre de usuario.
-const (
-	AnaID    = "00000000-0000-4000-8000-0000000000a1"
-	CarlosID = "00000000-0000-4000-8000-0000000000c1"
-)
-
-// ErrNotFound indica que el pedido no existe.
-var ErrNotFound = errors.New("pedido no encontrado")
-
-// Order es un pedido.
-type Order struct {
-	ID        int       `json:"id"`
-	Owner     string    `json:"owner"` // sub del cliente
-	Items     string    `json:"items"`
-	Total     float64   `json:"total"`
-	Status    string    `json:"status"`
-	CreatedAt time.Time `json:"created_at"`
-}
-
-// Statuses son los estados válidos de un pedido.
-var Statuses = []string{"Pendiente", "Enviado", "Entregado", "Cancelado"}
-
-// Store guarda los pedidos en memoria, seguro para uso concurrente.
-type Store struct {
-	mu     sync.Mutex
-	orders map[int]Order
-	nextID int
-}
-
-// NewStore crea un almacén con los pedidos de ejemplo de ana y carlos.
-func NewStore() *Store {
-	day := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
-	s := &Store{orders: make(map[int]Order), nextID: 1004}
-	for _, o := range []Order{
-		{1001, AnaID, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado", day},
-		{1002, AnaID, "Taza «go fmt» ×1", 9.50, "Pendiente", day.Add(48 * time.Hour)},
-		{1003, CarlosID, "Camiseta Keycloak ×2", 30.00, "Entregado", day.Add(24 * time.Hour)},
-	} {
-		s.orders[o.ID] = o
-	}
-	return s
-}
-
-// ByOwner devuelve los pedidos de un usuario, ordenados por número.
-func (s *Store) ByOwner(sub string) []Order {
-	return s.filter(func(o Order) bool { return o.Owner == sub })
-}
-
-// ByStatus devuelve los pedidos en un estado, ordenados por número.
-func (s *Store) ByStatus(status string) []Order {
-	return s.filter(func(o Order) bool { return o.Status == status })
-}
-
-// All devuelve todos los pedidos, ordenados por número.
-func (s *Store) All() []Order {
-	return s.filter(func(Order) bool { return true })
-}
-
-func (s *Store) filter(keep func(Order) bool) []Order {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	out := []Order{}
-	for _, o := range s.orders {
-		if keep(o) {
-			out = append(out, o)
-		}
-	}
-	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
-	return out
-}
-
-// Create registra un pedido nuevo de owner, calculando el total con los
-// precios del catálogo.
-func (s *Store) Create(owner, productID string, qty int) (Order, error) {
-	p, ok := FindProduct(productID)
-	if !ok {
-		return Order{}, fmt.Errorf("producto desconocido %q", productID)
-	}
-	if qty < 1 || qty > 10 {
-		return Order{}, fmt.Errorf("la cantidad debe estar entre 1 y 10")
-	}
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	o := Order{
-		ID:        s.nextID,
-		Owner:     owner,
-		Items:     fmt.Sprintf("%s ×%d", p.Name, qty),
-		Total:     p.Price * float64(qty),
-		Status:    "Pendiente",
-		CreatedAt: time.Now().UTC(),
-	}
-	s.orders[o.ID] = o
-	s.nextID++
-	return o, nil
-}
-
-// SetStatus cambia el estado de un pedido.
-func (s *Store) SetStatus(id int, status string) (Order, error) {
-	if !slices.Contains(Statuses, status) {
-		return Order{}, fmt.Errorf("estado inválido %q", status)
-	}
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	o, ok := s.orders[id]
-	if !ok {
-		return Order{}, ErrNotFound
-	}
-	o.Status = status
-	s.orders[id] = o
-	return o, nil
-}
-
-// Get devuelve un pedido por su número.
-func (s *Store) Get(id int) (Order, bool) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	o, ok := s.orders[id]
-	return o, ok
-}
internal/pedidos/puertos.go nuevo · +23 −0
@@ -0,0 +1,23 @@
+package pedidos
+
+import "context"
+
+// Repository es el puerto de salida donde se guardan los pedidos. El dominio
+// solo conoce esta interfaz; la implementación (en memoria, PostgreSQL…)
+// vive en internal/adaptadores.
+type Repository interface {
+	// Get devuelve un pedido o ErrNotFound.
+	Get(ctx context.Context, id int) (Order, error)
+	// List devuelve los pedidos que cumplen el filtro, ordenados por número.
+	List(ctx context.Context, f Filter) ([]Order, error)
+	// Add guarda un pedido nuevo y le asigna número.
+	Add(ctx context.Context, o Order) (Order, error)
+	// Update guarda los cambios de un pedido existente (o ErrNotFound).
+	Update(ctx context.Context, o Order) error
+}
+
+// Filter elige pedidos. Un campo vacío no filtra.
+type Filter struct {
+	Owner  string
+	Status string
+}
internal/pedidos/servicio.go nuevo · +99 −0
@@ -0,0 +1,99 @@
+package pedidos
+
+import (
+	"context"
+	"fmt"
+	"slices"
+	"time"
+)
+
+// Service reúne los casos de uso de api-pedidos. Cada método recibe al Actor
+// y aplica las reglas de negocio, incluidas las de permisos.
+type Service struct {
+	repo Repository
+	now  func() time.Time
+}
+
+// NewService crea el servicio sobre un repositorio.
+func NewService(repo Repository) *Service {
+	return &Service{repo: repo, now: func() time.Time { return time.Now().UTC() }}
+}
+
+// Mine devuelve los pedidos del actor.
+func (s *Service) Mine(ctx context.Context, a Actor) ([]Order, error) {
+	if !a.Can(PermBuy) {
+		return nil, ErrForbidden
+	}
+	return s.repo.List(ctx, Filter{Owner: a.ID})
+}
+
+// Get devuelve un pedido si es del actor o si el actor gestiona pedidos. Si
+// es de otra persona, responde ErrNotFound, no ErrForbidden: así no revela
+// qué pedidos existen.
+func (s *Service) Get(ctx context.Context, a Actor, id int) (Order, error) {
+	if !a.Can(PermBuy) && !a.Can(PermManage) {
+		return Order{}, ErrForbidden
+	}
+	o, err := s.repo.Get(ctx, id)
+	if err != nil {
+		return Order{}, err
+	}
+	if o.Owner != a.ID && !a.Can(PermManage) {
+		return Order{}, ErrNotFound
+	}
+	return o, nil
+}
+
+// Create registra un pedido a nombre del actor. El precio sale del catálogo:
+// nunca lo decide quien compra.
+func (s *Service) Create(ctx context.Context, a Actor, productID string, qty int) (Order, error) {
+	if !a.Can(PermBuy) {
+		return Order{}, ErrForbidden
+	}
+	p, ok := FindProduct(productID)
+	if !ok {
+		return Order{}, fmt.Errorf("%w: producto desconocido %q", ErrInvalid, productID)
+	}
+	if qty < 1 || qty > 10 {
+		return Order{}, fmt.Errorf("%w: la cantidad debe estar entre 1 y 10", ErrInvalid)
+	}
+	return s.repo.Add(ctx, Order{
+		Owner:     a.ID,
+		Items:     fmt.Sprintf("%s ×%d", p.Name, qty),
+		Total:     p.Price * float64(qty),
+		Status:    "Pendiente",
+		CreatedAt: s.now(),
+	})
+}
+
+// All devuelve todos los pedidos.
+func (s *Service) All(ctx context.Context, a Actor) ([]Order, error) {
+	if !a.Can(PermManage) {
+		return nil, ErrForbidden
+	}
+	return s.repo.List(ctx, Filter{})
+}
+
+// SetStatus cambia el estado de un pedido.
+func (s *Service) SetStatus(ctx context.Context, a Actor, id int, status string) (Order, error) {
+	if !a.Can(PermManage) {
+		return Order{}, ErrForbidden
+	}
+	if !slices.Contains(Statuses, status) {
+		return Order{}, fmt.Errorf("%w: estado inválido %q", ErrInvalid, status)
+	}
+	o, err := s.repo.Get(ctx, id)
+	if err != nil {
+		return Order{}, err
+	}
+	o.Status = status
+	return o, s.repo.Update(ctx, o)
+}
+
+// ToInvoice devuelve los pedidos de todos los clientes en un estado.
+func (s *Service) ToInvoice(ctx context.Context, a Actor, status string) ([]Order, error) {
+	if !a.Can(PermInvoice) {
+		return nil, ErrForbidden
+	}
+	return s.repo.List(ctx, Filter{Status: status})
+}

← Volver a la lección 13