Cambios de la lección 08
Todo lo que cambia en tienda/pasos/paso-08 respecto al paso anterior. Vuelve a la lección: 08. Token Exchange.
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/facturacion/main.go | modificado | +79 −24 |
cmd/web/main.go | modificado | +4 −2 |
infra/realm/tienda-realm.json | modificado | +31 −3 |
internal/apiauth/apiauth.go | modificado | +2 −0 |
internal/facturacion/server.go | nuevo | +115 −0 |
internal/facturasclient/client.go | nuevo | +88 −0 |
internal/tokenexchange/exchange.go | nuevo | +88 −0 |
internal/web/compras.go | modificado | +19 −0 |
internal/web/templates/pedidos.html | modificado | +13 −6 |
internal/web/web.go | modificado | +21 −5 |
cmd/facturacion/main.go
@@ -1,29 +1,41 @@
-// Command facturacion es el servicio de facturación: un proceso interno que,
-// sin ningún usuario delante, factura los pedidos entregados. Se autentica
-// ante Keycloak como client confidencial (Client Credentials).
+// Command facturacion es el servicio de facturación. Hace dos cosas:
//
-// Uso (desde tienda/pasos/paso-07):
+// - Sin usuario: factura cada 30 s los pedidos entregados, con su propio
+// token de Client Credentials (lección 7).
+// - En nombre de un usuario: atiende peticiones de tienda-web en el puerto
+// 8082 y, para consultar api-pedidos como ese usuario, intercambia su
+// token (Token Exchange, lección 8).
+//
+// Uso (desde tienda/pasos/paso-08):
//
// go run ./cmd/facturacion
package main
import (
"context"
+ "errors"
"log"
+ "net/http"
"os"
"os/signal"
+ "strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
+ "tienda/internal/apiauth"
"tienda/internal/facturacion"
+ "tienda/internal/tokenexchange"
)
func main() {
issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
+ clientID := env("OIDC_CLIENT_ID", "facturacion")
+ clientSecret := env("OIDC_CLIENT_SECRET", "facturacion-secret") // solo para desarrollo
apiURL := env("API_URL", "http://localhost:8081")
+ addr := env("ADDR", ":8082")
interval, err := time.ParseDuration(env("INTERVALO", "30s"))
if err != nil {
log.Fatalf("INTERVALO: %v", err)
@@ -36,32 +48,48 @@
if err != nil {
log.Fatal(err)
}
+ tokenURL := provider.Endpoint().TokenURL
+ store := facturacion.NewStore()
- // Client Credentials: el servicio se identifica con su client ID y su
- // secreto. No hay usuario, ni navegador, ni refresh token.
- cfg := clientcredentials.Config{
- ClientID: env("OIDC_CLIENT_ID", "facturacion"),
- ClientSecret: env("OIDC_CLIENT_SECRET", "facturacion-secret"), // solo para desarrollo
- TokenURL: provider.Endpoint().TokenURL,
+ // --- Lección 7: Client Credentials para el proceso automático ---
+ cc := clientcredentials.Config{ClientID: clientID, ClientSecret: clientSecret, TokenURL: tokenURL}
+ serviceClient := oauth2.NewClient(ctx, oauth2.ReuseTokenSource(nil, loggingSource{cc.TokenSource(ctx)}))
+ serviceClient.Timeout = 5 * time.Second
+ worker := &facturacion.Worker{API: apiURL, HTTP: serviceClient, Store: store, Interval: interval}
+ go worker.Run(ctx)
+
+ // --- Lección 8: HTTP para usuarios + Token Exchange ---
+ // Valida los tokens que trae tienda-web: su aud debe incluir «facturacion».
+ verifier, err := apiauth.NewVerifier(ctx, issuer, clientID)
+ if err != nil {
+ log.Fatal(err)
}
+ httpClient := &http.Client{Timeout: 5 * time.Second}
+ srv := &facturacion.Server{
+ Store: store,
+ Exchanger: &tokenexchange.Exchanger{
+ TokenURL: tokenURL, ClientID: clientID, ClientSecret: clientSecret, HTTP: httpClient,
+ },
+ API: apiURL,
+ HTTP: httpClient,
+ }
+ mux := http.NewServeMux()
+ srv.Register(mux, verifier)
- // ReuseTokenSource guarda el token y solo pide otro cuando caduca.
- // loggingSource nos deja ver en el log cuándo ocurre eso.
- ts := oauth2.ReuseTokenSource(nil, loggingSource{cfg.TokenSource(ctx)})
- client := oauth2.NewClient(ctx, ts) // añade «Authorization: Bearer» a cada petición
- client.Timeout = 5 * time.Second
-
- w := &facturacion.Worker{
- API: apiURL,
- HTTP: client,
- Store: facturacion.NewStore(),
- Interval: interval,
+ server := &http.Server{Addr: addr, Handler: logRequests(mux), ReadHeaderTimeout: 5 * time.Second}
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = server.Shutdown(shutdownCtx)
+ }()
+ log.Printf("facturacion escuchando en http://%s; facturando cada %s contra %s", listenHost(addr), interval, apiURL)
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatal(err)
}
- log.Printf("facturacion: facturando cada %s contra %s", interval, apiURL)
- w.Run(ctx)
}
-// loggingSource registra cada vez que hace falta un token nuevo de Keycloak.
+// loggingSource registra cada vez que hace falta un token de servicio nuevo.
type loggingSource struct{ src oauth2.TokenSource }
func (l loggingSource) Token() (*oauth2.Token, error) {
@@ -73,9 +101,36 @@
return tok, nil
}
+// statusRecorder recuerda el código de estado para el log.
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (s *statusRecorder) WriteHeader(code int) {
+ s.status = code
+ s.ResponseWriter.WriteHeader(code)
+}
+
+func logRequests(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rec, r)
+ log.Printf("%s %s → %d", r.Method, r.URL.Path, rec.status)
+ })
+}
+
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+
+// listenHost convierte ":3000" en "localhost:3000" para mostrar la URL.
+func listenHost(addr string) string {
+ if strings.HasPrefix(addr, ":") {
+ return "localhost" + addr
+ }
+ return addr
+}
cmd/web/main.go
@@ -15,6 +15,7 @@
"time"
"tienda/internal/auth"
+ "tienda/internal/facturasclient"
"tienda/internal/pedidosclient"
"tienda/internal/session"
"tienda/internal/web"
@@ -30,7 +31,8 @@
PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
}
addr := env("ADDR", ":3000")
- apiURL := env("API_URL", "http://localhost:8081") // api-pedidos
+ apiURL := env("API_URL", "http://localhost:8081") // api-pedidos
+ facturasURL := env("FACTURACION_URL", "http://localhost:8082") // facturacion
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -43,7 +45,7 @@
mux := http.NewServeMux()
a.Register(mux)
- web.Register(mux, a, pedidosclient.New(apiURL))
+ web.Register(mux, a, pedidosclient.New(apiURL), facturasclient.New(facturasURL))
srv := &http.Server{
Addr: addr,
infra/realm/tienda-realm.json
@@ -163,7 +163,8 @@
"roles",
"basic",
"email",
- "api-pedidos"
+ "api-pedidos",
+ "facturacion"
],
"optionalClientScopes": [
"address",
@@ -203,7 +204,8 @@
"organization",
"offline_access",
"microprofile-jwt",
- "pedidos:escribir"
+ "pedidos:escribir",
+ "facturacion"
]
},
{
@@ -238,7 +240,7 @@
{
"clientId": "facturacion",
"name": "Servicio de facturación",
- "description": "Servicio interno: Client Credentials (lección 7)",
+ "description": "Servicio interno: Client Credentials (lección 7) y Token Exchange (lección 8)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
@@ -248,6 +250,9 @@
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
+ "attributes": {
+ "standard.token.exchange.enabled": "true"
+ },
"defaultClientScopes": [
"web-origins",
"acr",
@@ -299,6 +304,29 @@
"display.on.consent.screen": "true",
"consent.screen.text": "Crear y modificar pedidos"
}
+ },
+ {
+ "name": "facturacion",
+ "description": "Añade facturacion a la audiencia (aud) del access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "audiencia facturacion",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "facturacion",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "introspection.token.claim": "true"
+ }
+ }
+ ]
},
{
"name": "email",
internal/apiauth/apiauth.go
@@ -24,6 +24,7 @@
Scopes []string // scope, separado por espacios
Roles []string // realm_access.roles
APIRoles []string // resource_access.<audiencia>.roles: roles de client de esta API
+ Token string // el access token en bruto, por si hay que intercambiarlo (lección 8)
}
// Verifier valida access tokens emitidos por un realm para una audiencia.
@@ -78,6 +79,7 @@
Scopes: strings.Fields(c.Scope),
Roles: c.RealmAccess.Roles,
APIRoles: c.ResourceAccess[v.audience].Roles,
+ Token: raw,
}, nil
}
internal/facturacion/server.go
@@ -0,0 +1,115 @@
+package facturacion
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+
+ "golang.org/x/oauth2"
+
+ "tienda/internal/apiauth"
+ "tienda/internal/jsonhttp"
+ "tienda/internal/tokenexchange"
+)
+
+// Server expone la facturación a los usuarios (a través de tienda-web).
+type Server struct {
+ Store *Store
+ Exchanger *tokenexchange.Exchanger
+ API string // URL base de api-pedidos
+ HTTP *http.Client // cliente sin token: el token lo ponemos a mano
+}
+
+// Register añade las rutas. Exigen un access token de usuario cuyo aud
+// incluya «facturacion» (lo valida v) y el rol cliente o admin.
+func (s *Server) Register(mux *http.ServeMux, v *apiauth.Verifier) {
+ usuario := apiauth.RequireRole("cliente", "admin")
+ mux.Handle("GET /facturas", v.Middleware(usuario(http.HandlerFunc(s.list))))
+ mux.Handle("POST /facturas", v.Middleware(usuario(http.HandlerFunc(s.request))))
+}
+
+// list devuelve las facturas de quien llama. No necesita a api-pedidos: las
+// facturas son datos de este servicio y el «sub» del token dice de quién son.
+func (s *Server) list(w http.ResponseWriter, r *http.Request) {
+ p := apiauth.FromContext(r.Context())
+ jsonhttp.Write(w, http.StatusOK, map[string]any{"facturas": s.Store.ByCustomer(p.Subject)})
+}
+
+// request emite una factura anticipada de un pedido del usuario. Para saber
+// si el pedido es suyo y en qué estado está, pregunta a api-pedidos EN NOMBRE
+// DEL USUARIO: cambia su token por uno para api-pedidos (token exchange), así
+// es la API quien aplica sus reglas (un cliente solo ve sus pedidos).
+func (s *Server) request(w http.ResponseWriter, r *http.Request) {
+ p := apiauth.FromContext(r.Context())
+ var body struct {
+ Pedido int `json:"pedido"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil || body.Pedido <= 0 {
+ jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", `se espera {"pedido": <número>}`)
+ return
+ }
+
+ // 1. Token exchange: el token de ana para facturacion → uno de ana para api-pedidos.
+ tok, err := s.Exchanger.Exchange(r.Context(), p.Token, "api-pedidos")
+ if err != nil {
+ log.Printf("intercambio fallido: %v", err)
+ var oe *tokenexchange.Error
+ if errors.As(err, &oe) && oe.Description == "Invalid token" {
+ jsonhttp.Error(w, http.StatusUnauthorized, "invalid_token", "la sesión del usuario ya no es válida")
+ return
+ }
+ jsonhttp.Error(w, http.StatusBadGateway, "exchange_failed", "no se pudo actuar en nombre del usuario")
+ return
+ }
+
+ // 2. Con ese token, api-pedidos nos dice si el pedido es de ana.
+ order, status, err := s.getOrder(r.Context(), tok, body.Pedido)
+ switch {
+ case err != nil:
+ log.Printf("api-pedidos: %v", err)
+ jsonhttp.Error(w, http.StatusBadGateway, "api_error", "api-pedidos no respondió")
+ return
+ case status == http.StatusNotFound:
+ jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
+ return
+ case status != http.StatusOK:
+ jsonhttp.Error(w, http.StatusBadGateway, "api_error", fmt.Sprintf("api-pedidos respondió %d", status))
+ return
+ }
+ if order.Status != "Enviado" && order.Status != "Entregado" {
+ jsonhttp.Error(w, http.StatusConflict, "invalid_state", "solo se facturan pedidos enviados o entregados")
+ return
+ }
+
+ // 3. Emitimos la factura (o devolvemos la que ya tuviera).
+ inv, created := s.Store.Issue(order.ID, p.Subject, order.Total, "solicitada")
+ code := http.StatusOK
+ if created {
+ code = http.StatusCreated
+ log.Printf("factura %s solicitada por %s para el pedido #%d", inv.Number, p.Username, order.ID)
+ }
+ jsonhttp.Write(w, code, inv)
+}
+
+// getOrder pide un pedido a api-pedidos con el token intercambiado.
+func (s *Server) getOrder(ctx context.Context, tok *oauth2.Token, id int) (Order, int, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.API+"/pedidos/"+strconv.Itoa(id), nil)
+ if err != nil {
+ return Order{}, 0, err
+ }
+ tok.SetAuthHeader(req)
+ resp, err := s.HTTP.Do(req)
+ if err != nil {
+ return Order{}, 0, err
+ }
+ defer resp.Body.Close()
+ var o Order
+ if resp.StatusCode == http.StatusOK {
+ err = json.NewDecoder(resp.Body).Decode(&o)
+ }
+ return o, resp.StatusCode, err
+}
internal/facturasclient/client.go
@@ -0,0 +1,88 @@
+// Package facturasclient es el cliente HTTP con el que tienda-web llama al
+// servicio de facturación en nombre del usuario.
+package facturasclient
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+// Invoice es una factura tal como la devuelve facturacion.
+type Invoice struct {
+ Number string `json:"numero"`
+ OrderID int `json:"pedido"`
+ Total float64 `json:"total"`
+ Origin string `json:"origen"`
+}
+
+// Client llama a facturacion.
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+// New crea un cliente para facturacion en baseURL (p. ej. http://localhost:8082).
+func New(baseURL string) *Client {
+ return &Client{baseURL: baseURL, http: &http.Client{Timeout: 10 * time.Second}}
+}
+
+// MyInvoices devuelve las facturas del usuario, indexadas por número de pedido.
+func (c *Client) MyInvoices(ctx context.Context, tok *oauth2.Token) (map[int]Invoice, error) {
+ var out struct {
+ Facturas []Invoice `json:"facturas"`
+ }
+ if err := c.do(ctx, tok, http.MethodGet, "/facturas", nil, &out); err != nil {
+ return nil, err
+ }
+ m := make(map[int]Invoice, len(out.Facturas))
+ for _, inv := range out.Facturas {
+ m[inv.OrderID] = inv
+ }
+ return m, nil
+}
+
+// Request pide la factura anticipada de un pedido.
+func (c *Client) Request(ctx context.Context, tok *oauth2.Token, orderID int) (Invoice, error) {
+ var inv Invoice
+ err := c.do(ctx, tok, http.MethodPost, "/facturas", map[string]int{"pedido": orderID}, &inv)
+ return inv, err
+}
+
+func (c *Client) do(ctx context.Context, tok *oauth2.Token, method, path string, body, out any) error {
+ var rd io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ rd = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd)
+ if err != nil {
+ return err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ tok.SetAuthHeader(req)
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ var e struct {
+ Description string `json:"error_description"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&e)
+ return fmt.Errorf("facturacion respondió %s: %s", resp.Status, e.Description)
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
internal/tokenexchange/exchange.go
@@ -0,0 +1,88 @@
+// Package tokenexchange implementa el Token Exchange de OAuth 2.0 (RFC 8693)
+// tal como lo soporta Keycloak («standard token exchange»): un client
+// confidencial cambia el access token de un usuario por otro, emitido a su
+// nombre y dirigido a otra audiencia.
+package tokenexchange
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+const (
+ grantType = "urn:ietf:params:oauth:grant-type:token-exchange"
+ accessTokenType = "urn:ietf:params:oauth:token-type:access_token"
+)
+
+// Exchanger hace token exchange en nombre de un client confidencial.
+type Exchanger struct {
+ TokenURL string // endpoint de token del realm
+ ClientID string // el client que intercambia (debe estar en el aud del token original)
+ ClientSecret string
+ HTTP *http.Client
+}
+
+// Error es un error OAuth devuelto por Keycloak («error», «error_description»).
+type Error struct {
+ Code string `json:"error"`
+ Description string `json:"error_description"`
+}
+
+func (e *Error) Error() string { return fmt.Sprintf("token exchange: %s: %s", e.Code, e.Description) }
+
+// Exchange cambia subjectToken (el access token del usuario) por un access
+// token para audience. El token nuevo conserva el usuario (sub) y su sesión,
+// pero su azp es este client y su aud queda reducida a audience.
+func (e *Exchanger) Exchange(ctx context.Context, subjectToken, audience string) (*oauth2.Token, error) {
+ form := url.Values{
+ "grant_type": {grantType},
+ "subject_token": {subjectToken},
+ "subject_token_type": {accessTokenType},
+ "requested_token_type": {accessTokenType},
+ "audience": {audience},
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.TokenURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(url.QueryEscape(e.ClientID), url.QueryEscape(e.ClientSecret)) // RFC 6749 §2.3.1
+
+ client := e.HTTP
+ if client == nil {
+ client = http.DefaultClient
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ oe := &Error{}
+ if json.NewDecoder(resp.Body).Decode(oe) != nil || oe.Code == "" {
+ return nil, fmt.Errorf("token exchange: Keycloak respondió %s", resp.Status)
+ }
+ return nil, oe
+ }
+ var body struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, err
+ }
+ return &oauth2.Token{
+ AccessToken: body.AccessToken,
+ TokenType: body.TokenType,
+ Expiry: time.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
+ }, nil
+}
internal/web/compras.go
@@ -87,6 +87,25 @@
http.Redirect(w, r, "/admin?ok="+strconv.Itoa(id), http.StatusSeeOther)
}
+// pedirFactura pide a facturacion la factura anticipada de un pedido.
+func (h *handlers) pedirFactura(w http.ResponseWriter, r *http.Request) {
+ _, tok, ok := h.apiToken(w, r, "%2Fpedidos")
+ if !ok {
+ return
+ }
+ id, err := strconv.Atoi(r.PostFormValue("pedido"))
+ if err != nil {
+ http.Error(w, "pedido inválido", http.StatusBadRequest)
+ return
+ }
+ if _, err := h.facturas.Request(r.Context(), tok, id); err != nil {
+ log.Printf("pedir factura: %v", err)
+ http.Redirect(w, r, "/pedidos?error=factura", http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, "/pedidos?factura="+strconv.Itoa(id), http.StatusSeeOther)
+}
+
// errorCode resume un error de la API en un código corto para la URL.
func errorCode(err error) string {
switch {
internal/web/templates/pedidos.html
@@ -2,18 +2,25 @@
<h1>Mis pedidos</h1>
<p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}}), servidos por <code>api-pedidos</code></p>
{{if .Flash}}<div class="note">✔ {{.Flash}}</div>{{end}}
-{{if .Error}}
-<div class="note">⚠ {{.Error}}</div>
-{{else if .Orders}}
+{{if .Error}}<div class="note">⚠ {{.Error}}</div>{{end}}
+{{if .Orders}}
<table>
- <thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th></tr></thead>
+ <thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th><th>Factura</th></tr></thead>
<tbody>
{{range .Orders}}
- <tr><td>#{{.ID}}</td><td>{{.Items}}</td><td>$ {{printf "%.2f" .Total}}</td><td><span class="badge">{{.Status}}</span></td></tr>
+ <tr><td>#{{.ID}}</td><td>{{.Items}}</td><td>$ {{printf "%.2f" .Total}}</td><td><span class="badge">{{.Status}}</span></td>
+ <td>
+ {{$inv := index $.Invoices .ID}}
+ {{if $inv.Number}}<strong>{{$inv.Number}}</strong> <span class="who">({{$inv.Origin}})</span>
+ {{else if or (eq .Status "Enviado") (eq .Status "Entregado")}}
+ <form class="inline" method="post" action="/facturas"><input type="hidden" name="pedido" value="{{.ID}}"><button class="btn ghost" type="submit">Pedir factura</button></form>
+ {{else}}—{{end}}
+ </td>
+ </tr>
{{end}}
</tbody>
</table>
-{{else}}
+{{else if not .Error}}
<div class="note">Todavía no tienes pedidos.</div>
{{end}}
{{end}}
internal/web/web.go
@@ -13,6 +13,7 @@
"time"
"tienda/internal/auth"
+ "tienda/internal/facturasclient"
"tienda/internal/pedidos"
"tienda/internal/pedidosclient"
"tienda/internal/session"
@@ -40,6 +41,7 @@
Session *session.Session
Products []pedidos.Product
Orders []pedidosclient.Order
+ Invoices map[int]facturasclient.Invoice // facturas por número de pedido
Statuses []string
Claims []claim
UserInfo []claim
@@ -49,14 +51,15 @@
}
type handlers struct {
- auth *auth.Auth
- api *pedidosclient.Client
- pages map[string]*template.Template
+ auth *auth.Auth
+ api *pedidosclient.Client
+ facturas *facturasclient.Client
+ pages map[string]*template.Template
}
// Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
-func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client) {
- h := &handlers{auth: a, api: api, pages: make(map[string]*template.Template)}
+func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client, facturas *facturasclient.Client) {
+ h := &handlers{auth: a, api: api, facturas: facturas, pages: make(map[string]*template.Template)}
for _, name := range []string{"home.html", "pedidos.html", "perfil.html", "admin.html"} {
h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
}
@@ -68,6 +71,7 @@
mux.Handle("POST /comprar", a.RequireLogin(http.HandlerFunc(h.comprar)))
mux.Handle("GET /admin", a.RequireLogin(http.HandlerFunc(h.admin)))
mux.Handle("POST /admin/pedidos/{id}", a.RequireLogin(http.HandlerFunc(h.cambiarEstado)))
+ mux.Handle("POST /facturas", a.RequireLogin(http.HandlerFunc(h.pedirFactura)))
}
func (h *handlers) home(w http.ResponseWriter, r *http.Request) {
@@ -90,6 +94,12 @@
if id := r.URL.Query().Get("nuevo"); id != "" {
data.Flash = "Pedido #" + id + " creado."
}
+ if f := r.URL.Query().Get("factura"); f != "" {
+ data.Flash = "Factura emitida para el pedido #" + f + "."
+ }
+ if r.URL.Query().Get("error") == "factura" {
+ data.Error = "No se pudo emitir la factura. Mira el log de tienda-web."
+ }
if err != nil {
data.Error = err.Error()
} else if orders, err := h.api.MyOrders(r.Context(), tok); err != nil {
@@ -97,6 +107,12 @@
data.Error = "No se pudieron cargar los pedidos: " + err.Error()
} else {
data.Orders = orders
+ // Las facturas vienen de otro servicio; si no responde, la página sigue funcionando.
+ if inv, err := h.facturas.MyInvoices(r.Context(), tok); err != nil {
+ log.Printf("facturacion: %v", err)
+ } else {
+ data.Invoices = inv
+ }
}
h.render(w, "pedidos.html", data)
}