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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/api/main.go | modificado | +11 −3 |
internal/adaptadores/keycloak/keycloak.go | nuevo | +54 −0 |
internal/adaptadores/memoria/memoria.go | nuevo | +91 −0 |
internal/adaptadores/rest/rest.go | nuevo | +176 −0 |
internal/api/api.go | eliminado | +0 −135 |
internal/pedidos/actor.go | nuevo | +24 −0 |
internal/pedidos/pedido.go | nuevo | +30 −0 |
internal/pedidos/pedidos.go | eliminado | +0 −133 |
internal/pedidos/puertos.go | nuevo | +23 −0 |
internal/pedidos/servicio.go | nuevo | +99 −0 |
cmd/api/main.go
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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})
+}