Capítulo 01 · Base

Fundamentos web: cómo se hablan las piezas

Antes de escribir una línea de React o Flutter necesitas dominar el idioma en el que todas las piezas del sistema se comunican: HTTP, JSON y WebSocket. Este capítulo construye ese vocabulario con ejemplos del restaurante.

1.1 Frontend y backend: quién hace qué

Una aplicación web o móvil moderna se divide en dos mundos que colaboran pero tienen responsabilidades muy distintas:

ResponsabilidadFrontendBackend
Interfaz de usuario (pantallas, botones, formularios)✔ toda
Validación de datos✔ primera línea (UX: avisar rápido)✔ definitiva (nunca confíes en el cliente)
Reglas de negocio (precios, descuentos, estados del pedido)✔ única fuente de verdad
Persistencia (base de datos)solo caché local
Autenticación y autorizaciónguarda el token, oculta pantallas✔ verifica cada petición
Comunicación con impresoras y hardwarea veces (impresora local vía Tauri)✔ normalmente (cola de impresión)
Estado en tiempo real (quién está conectado, pedidos activos)refleja lo que el servidor le dice✔ coordina y difunde por WebSocket

La regla de oro: el frontend puede mentir. Cualquiera puede abrir las herramientas de desarrollador, modificar el JavaScript o mandar peticiones a mano con curl. Por eso toda validación, todo cálculo de precio y toda regla de permisos que importe debe repetirse en el backend. La validación del frontend existe solo para dar buena experiencia (avisar errores sin esperar al servidor), no para proteger nada.

En nuestro restaurante: cuando el mesero manda un pedido, la app Flutter no calcula el total y lo manda al servidor. Manda solo los IDs de los platillos y las cantidades, y el backend busca los precios en su base de datos y calcula el total. Si el frontend calculara el total, un cliente malicioso podría mandar "total": 1.

1.2 Anatomía de una petición HTTP

HTTP es el protocolo de conversación entre cliente y servidor. Cada interacción es un par petición → respuesta: el cliente siempre inicia, el servidor siempre contesta, y después la conexión "se olvida" de todo (HTTP es sin estado o stateless). Así se ve una petición real, cruda, tal como viaja por la red:

POST /api/pedidos HTTP/1.1              ← línea de petición: VERBO + ruta + versión
Host: 192.168.1.50:8080                 ┐
Content-Type: application/json          │ headers: metadatos
Authorization: Bearer eyJhbGciOiJIUzI1… │ (pares clave: valor)
Content-Length: 154                     ┘
                                        ← línea en blanco separa headers del body
{                                       ┐
  "mesaId": 7,                          │ body: los datos
  "items": [                            │ (aquí, JSON)
    { "platilloId": 12, "cantidad": 2 } │
  ]                                     ┘
}

Y la respuesta que devuelve el servidor tiene la misma estructura: línea de estado, headers y body.

HTTP/1.1 201 Created                    ← línea de estado: versión + código + texto
Content-Type: application/json
Location: /api/pedidos/341              ← header útil: dónde quedó el recurso creado

{
  "id": 341,
  "mesaId": 7,
  "estado": "PENDIENTE",
  "total": 260.00,
  "creadoEn": "2026-07-28T14:03:22Z"
}

Los headers que usarás todos los días

HeaderDirecciónPara qué sirve
Content-TypeambasFormato del body: application/json casi siempre en APIs. Si lo omites en un POST, muchos frameworks rechazan la petición.
AuthorizationpeticiónCredenciales. Con JWT: Bearer <token>. Lo verás a fondo en el capítulo 2.
AcceptpeticiónQué formatos acepta el cliente como respuesta (application/json).
LocationrespuestaURL del recurso recién creado (acompaña al código 201).
Cache-ControlrespuestaSi el cliente puede cachear la respuesta y por cuánto tiempo.
Content-LengthambasTamaño del body en bytes; lo pone la librería HTTP por ti.

Nunca escribirás estos bytes a mano: en React usarás fetch o axios, en Flutter http o dio, y ellas arman la petición. Pero entender la estructura cruda es lo que te permite depurar con la pestaña Network del navegador o con herramientas como Postman, Insomnia o Bruno cuando algo falla.

1.3 Los verbos HTTP

El verbo (o método) declara la intención de la petición. Usar el verbo correcto no es cosmético: los intermediarios (cachés, proxies) y los frameworks se comportan distinto según el verbo, y tu API se vuelve predecible para cualquiera que la lea.

VerboIntención¿Lleva body?IdempotenteEjemplo en el restaurante
GETLeer, nunca modificarNoGET /api/pedidos?estado=PENDIENTE
POSTCrear un recurso nuevo, o disparar una acciónNoPOST /api/pedidos (cada llamada crea otro pedido)
PUTReemplazar un recurso completoPUT /api/platillos/12 con el platillo entero
PATCHModificar parcialmenteDependePATCH /api/pedidos/341 con {"estado":"LISTO"}
DELETEEliminarNormalmente noDELETE /api/platillos/12

Idempotente significa que repetir la misma petición N veces deja al sistema igual que hacerla una sola vez. DELETE /api/platillos/12 dos veces sigue dejando el platillo borrado (la segunda devuelve 404, pero no rompe nada). En cambio POST /api/pedidos dos veces crea dos pedidos — por eso las apps deben deshabilitar el botón "Enviar pedido" mientras la petición está en vuelo, o usar una clave de idempotencia (un header Idempotency-Key con un UUID generado por el cliente que el servidor recuerda para no duplicar).

1.4 API REST: recursos, rutas y códigos de estado

REST es una convención para organizar tu API alrededor de recursos (sustantivos: pedidos, platillos, mesas) en lugar de acciones (verbos: crearPedido, obtenerMesas). La acción la pone el verbo HTTP; la ruta solo nombra el recurso:

✔ BIEN (recurso + verbo HTTP)          ✘ MAL (acción en la ruta)
GET    /api/pedidos                     GET  /api/obtenerPedidos
GET    /api/pedidos/341                 POST /api/consultarPedido
POST   /api/pedidos                     POST /api/crearNuevoPedido
PATCH  /api/pedidos/341                 POST /api/actualizarEstadoPedido
GET    /api/mesas/7/pedidos             GET  /api/pedidosPorMesa?id=7

Códigos de estado: el vocabulario de la respuesta

CódigoSignificadoCuándo lo devuelve tu API
200 OKTodo bienGET exitoso, PATCH exitoso
201 CreatedRecurso creadoPOST exitoso; incluye header Location
204 No ContentBien, sin bodyDELETE exitoso
400 Bad RequestEl cliente mandó datos inválidosJSON malformado, falta un campo, cantidad negativa
401 UnauthorizedNo sé quién eresFalta el token o expiró → el cliente debe re-loguearse
403 ForbiddenSé quién eres, pero no puedesUn mesero intenta borrar un platillo (solo ADMIN)
404 Not FoundNo existeGET /api/pedidos/99999
409 ConflictConflicto de estadoCancelar un pedido que ya está ENTREGADO
422 UnprocessableJSON válido, negocio inválidoPedido sin items (algunos equipos usan 400 para esto; sé consistente)
500 Internal Server ErrorEl servidor fallóBug, base de datos caída. Nunca lo devuelvas a propósito

Regla mnemotécnica: 4xx = la culpa es del cliente (puede corregir y reintentar), 5xx = la culpa es del servidor (reintentar puede funcionar más tarde). Tus apps deben tratar ambos distinto: un 400 se muestra al usuario como error de captura; un 500 se muestra como "algo falló, intenta de nuevo" y se registra para investigarlo.

1.5 Diseño de endpoints: headers y bodies completos

Un endpoint bien documentado especifica cuatro cosas: verbo + ruta, headers requeridos, body de entrada y body de salida (con sus códigos). Documenta así los tuyos desde el día uno — este es el formato que usaremos en el contrato completo de la API del capítulo 3. Ejemplo con el endpoint más importante del sistema:

POST /api/pedidos
─────────────────────────────────────────────────────────
Headers requeridos:
  Content-Type:  application/json
  Authorization: Bearer <jwt>        (rol: MESERO o ADMIN)

Body de entrada:
  mesaId   number   requerido si tipo=MESA
  tipo     string   "MESA" | "LLEVAR" | "DOMICILIO"
  items    array    mínimo 1 elemento
    ├─ platilloId  number  debe existir en el menú
    ├─ cantidad    number  entero ≥ 1
    └─ notas       string  opcional ("sin cebolla")

Respuestas:
  201 Created  → PedidoResponse (el pedido con id, total y estado)
                 Location: /api/pedidos/{id}
  400          → { "error": "items no puede estar vacío" }
  401          → token ausente o inválido
  403          → el rol no permite crear pedidos

Y así se consume ese endpoint desde cada frontend. Observa que la estructura es idéntica: URL, verbo, headers, body serializado a JSON:

export interface NuevoPedido {
  mesaId?: number;
  tipo: "MESA" | "LLEVAR" | "DOMICILIO";
  items: { platilloId: number; cantidad: number; notas?: string }[];
}

export async function crearPedido(pedido: NuevoPedido) {
  const resp = await fetch("http://192.168.1.50:8080/api/pedidos", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${localStorage.getItem("token")}`,
    },
    body: JSON.stringify(pedido),   // objeto JS → texto JSON
  });

  if (!resp.ok) {
    // resp.ok es true solo para códigos 2xx
    const error = await resp.json().catch(() => ({}));
    throw new Error(error.error ?? `Error HTTP ${resp.status}`);
  }
  return resp.json();               // texto JSON → objeto JS
}
import 'package:dio/dio.dart';

final dio = Dio(BaseOptions(
  baseUrl: 'http://192.168.1.50:8080',
  headers: {'Content-Type': 'application/json'},
));

Future<Map<String, dynamic>> crearPedido({
  int? mesaId,
  required String tipo,
  required List<Map<String, dynamic>> items,
}) async {
  final resp = await dio.post(
    '/api/pedidos',
    data: {'mesaId': mesaId, 'tipo': tipo, 'items': items},
    options: Options(headers: {
      'Authorization': 'Bearer ${await leerToken()}',
    }),
  );
  // dio lanza DioException automáticamente si el código no es 2xx
  return resp.data as Map<String, dynamic>;
}

1.6 DTO, JSON y el body de una request: la misma cosa en tres momentos

Estos tres términos confunden porque describen el mismo dato en tres momentos de su viaje:

  1. Un DTO (Data Transfer Object) es una clase o struct en tu código cuyo único propósito es transportar datos entre sistemas. No tiene lógica de negocio: solo campos.
  2. JSON es el formato de texto al que ese objeto se convierte para viajar por la red (serialización).
  3. El body de la request es ese texto JSON ya montado dentro de la petición HTTP, viajando del cliente al servidor.
DTO en el cliente class NuevoPedido { int mesaId; ... } JSON en el body HTTP POST /api/pedidos {"mesaId":7, "items":[...]} DTO en el servidor record NuevoPedidoDTO( Integer mesaId, ...) + validación serializar deserializar
El mismo dato: objeto → texto → objeto. Los lenguajes de cada lado pueden ser distintos; el JSON es el idioma neutral.

Así se ve el mismo DTO en cada tecnología del stack. Fíjate que el JSON del centro del diagrama es idéntico para todos:

// Un record de Java es la forma moderna de escribir un DTO:
// inmutable, solo campos, sin boilerplate.
// Las anotaciones de jakarta.validation validan el body automáticamente.
public record NuevoPedidoDTO(
    Integer mesaId,

    @NotNull @Pattern(regexp = "MESA|LLEVAR|DOMICILIO")
    String tipo,

    @NotEmpty @Valid
    List<ItemPedidoDTO> items
) {}

public record ItemPedidoDTO(
    @NotNull Long platilloId,
    @NotNull @Min(1) Integer cantidad,
    String notas
) {}

// En el controller, @RequestBody deserializa el JSON al DTO
// y @Valid dispara las validaciones (si fallan → 400 automático):
@PostMapping("/api/pedidos")
public ResponseEntity<PedidoResponse> crear(
        @Valid @RequestBody NuevoPedidoDTO dto) {
    PedidoResponse creado = pedidoService.crear(dto);
    return ResponseEntity
        .created(URI.create("/api/pedidos/" + creado.id()))
        .body(creado);
}
// En Go, el DTO es un struct con "tags": los `json:"..."` mapean
// campo ↔ clave JSON, y los `binding:"..."` validan el body.
type NuevoPedidoDTO struct {
    MesaID *int             `json:"mesaId"`
    Tipo   string           `json:"tipo" binding:"required,oneof=MESA LLEVAR DOMICILIO"`
    Items  []ItemPedidoDTO  `json:"items" binding:"required,min=1,dive"`
}

type ItemPedidoDTO struct {
    PlatilloID int64  `json:"platilloId" binding:"required"`
    Cantidad   int    `json:"cantidad" binding:"required,min=1"`
    Notas      string `json:"notas"`
}

// En el handler, ShouldBindJSON deserializa y valida en un paso:
func CrearPedido(c *gin.Context) {
    var dto NuevoPedidoDTO
    if err := c.ShouldBindJSON(&dto); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    creado, err := pedidoService.Crear(dto)
    if err != nil {
        c.JSON(500, gin.H{"error": "no se pudo crear el pedido"})
        return
    }
    c.Header("Location", fmt.Sprintf("/api/pedidos/%d", creado.ID))
    c.JSON(201, creado)
}
// En TypeScript el DTO es una interface: no existe en runtime,
// solo le da forma al objeto que serializarás con JSON.stringify.
export interface NuevoPedidoDTO {
  mesaId?: number;
  tipo: "MESA" | "LLEVAR" | "DOMICILIO";
  items: ItemPedidoDTO[];
}

export interface ItemPedidoDTO {
  platilloId: number;
  cantidad: number;
  notas?: string;
}

// Lo que devuelve el servidor también merece su tipo:
export interface PedidoResponse {
  id: number;
  mesaId: number | null;
  estado: "PENDIENTE" | "EN_PREPARACION" | "LISTO" | "ENTREGADO";
  total: number;
  creadoEn: string;   // las fechas viajan como texto ISO-8601
}
// En Dart escribes la serialización a mano (o la generas con
// json_serializable). toJson produce el Map que dio convierte a JSON.
class NuevoPedidoDTO {
  final int? mesaId;
  final String tipo; // 'MESA' | 'LLEVAR' | 'DOMICILIO'
  final List<ItemPedidoDTO> items;

  NuevoPedidoDTO({this.mesaId, required this.tipo, required this.items});

  Map<String, dynamic> toJson() => {
        'mesaId': mesaId,
        'tipo': tipo,
        'items': items.map((i) => i.toJson()).toList(),
      };
}

class ItemPedidoDTO {
  final int platilloId;
  final int cantidad;
  final String? notas;

  ItemPedidoDTO({required this.platilloId, required this.cantidad, this.notas});

  Map<String, dynamic> toJson() =>
      {'platilloId': platilloId, 'cantidad': cantidad, 'notas': notas};
}

Por qué usar DTOs y no las entidades de la base de datos

En el backend tendrás una entidad Pedido mapeada a la tabla de la base de datos. Es tentador devolverla directamente como JSON, pero el DTO intermedio existe por tres razones:

1.7 WebSocket: cuando el servidor necesita hablar primero

HTTP tiene una limitación estructural: el servidor no puede iniciar la conversación. Si la cocina quiere enterarse de un pedido nuevo con HTTP puro, tendría que preguntar cada pocos segundos ("¿hay algo nuevo?", polling), lo cual desperdicia recursos y aun así llega tarde.

Un WebSocket es una conexión que empieza como una petición HTTP normal pero se "transforma" (upgrade) en un canal bidireccional y permanente: cualquiera de los dos lados puede enviar mensajes en cualquier momento, sin volver a conectar.

Cliente → Servidor (petición HTTP normal con headers especiales):
  GET /ws/cocina HTTP/1.1
  Host: 192.168.1.50:8080
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Servidor → Cliente (acepta el cambio de protocolo):
  HTTP/1.1 101 Switching Protocols
  Upgrade: websocket
  Connection: Upgrade

A partir de aquí ya no hay peticiones ni respuestas:
solo mensajes (frames) en ambas direcciones, cuando haga falta.

REST o WebSocket: cómo decidir

REST · pedir y recibir

  • El cliente decide cuándo necesita datos
  • Operaciones puntuales: crear pedido, login, leer el menú
  • Fácil de cachear, depurar y probar (curl/Postman)
  • Cada petición lleva sus headers (incluido el token)
  • Úsalo para: todo el CRUD del restaurante

WebSocket · escuchar en vivo

  • El servidor empuja datos cuando ocurren
  • Flujos continuos: ubicación del repartidor, pedidos llegando a cocina, notificación de llamada
  • Conexión persistente que hay que cuidar (reconexión)
  • Se autentica una vez, al conectar
  • Úsalo para: mapa en vivo, KDS de cocina, avisos

La combinación ganadora, y la que usa esta guía en todos los flujos: escribir por REST, escuchar por WebSocket. El mesero crea el pedido con POST /api/pedidos (confiable, con confirmación y código de estado) y la cocina se entera al instante porque el backend difunde el evento por WebSocket a quien esté suscrito. Evita usar el WebSocket para enviar comandos críticos: REST te da reintentos, códigos de error y trazabilidad gratis.

Un cliente WebSocket mínimo en el navegador se ve así (la versión completa con reconexión está en el capítulo 4.2, y el servidor en el capítulo 3.3):

const ws = new WebSocket("ws://192.168.1.50:8080/ws/cocina?token=" + token);

ws.onopen    = () => console.log("conectado al canal de cocina");
ws.onmessage = (evento) => {
  const mensaje = JSON.parse(evento.data);
  // el backend etiqueta cada mensaje con un "type" para poder
  // multiplexar varios eventos por el mismo canal:
  if (mensaje.type === "PEDIDO_NUEVO") {
    agregarPedidoALaPantalla(mensaje.pedido);
  }
};
ws.onclose   = () => reintentarConexion();   // ¡siempre maneja el cierre!

Mensajes con type: WebSocket entrega texto plano, sin estructura. La convención universal es enviar JSON con un campo type que identifica el evento (PEDIDO_NUEVO, UBICACION, LLAMADA_ENTRANTE) y el resto del payload como datos. Definir estos tipos es diseñar el "contrato" de tu canal en tiempo real, igual que los endpoints son el contrato REST.