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:
- El frontend es todo lo que corre en el dispositivo del usuario: la app de escritorio (React + Tauri), la app móvil (Flutter) o una página web. Su trabajo es mostrar información y capturar intenciones del usuario.
- El backend es el programa que corre en un servidor (en tu caso, una computadora en el restaurante o un servidor en la nube). Su trabajo es guardar la verdad, decidir qué está permitido y coordinar a todos los clientes.
| Responsabilidad | Frontend | Backend |
|---|---|---|
| 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ón | guarda el token, oculta pantallas | ✔ verifica cada petición |
| Comunicación con impresoras y hardware | a 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
| Header | Dirección | Para qué sirve |
|---|---|---|
Content-Type | ambas | Formato del body: application/json casi siempre en APIs. Si lo omites en un POST, muchos frameworks rechazan la petición. |
Authorization | petición | Credenciales. Con JWT: Bearer <token>. Lo verás a fondo en el capítulo 2. |
Accept | petición | Qué formatos acepta el cliente como respuesta (application/json). |
Location | respuesta | URL del recurso recién creado (acompaña al código 201). |
Cache-Control | respuesta | Si el cliente puede cachear la respuesta y por cuánto tiempo. |
Content-Length | ambas | Tamañ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.
| Verbo | Intención | ¿Lleva body? | Idempotente | Ejemplo en el restaurante |
|---|---|---|---|---|
| GET | Leer, nunca modificar | No | Sí | GET /api/pedidos?estado=PENDIENTE |
| POST | Crear un recurso nuevo, o disparar una acción | Sí | No | POST /api/pedidos (cada llamada crea otro pedido) |
| PUT | Reemplazar un recurso completo | Sí | Sí | PUT /api/platillos/12 con el platillo entero |
| PATCH | Modificar parcialmente | Sí | Depende | PATCH /api/pedidos/341 con {"estado":"LISTO"} |
| DELETE | Eliminar | Normalmente no | Sí | DELETE /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
- Plural siempre:
/pedidos, no/pedido. La colección es/pedidosy un elemento es/pedidos/341. - Anidación para relaciones:
/mesas/7/pedidos= los pedidos de la mesa 7. No anides más de dos niveles. - Query params para filtrar, ordenar y paginar:
/api/pedidos?estado=PENDIENTE&pagina=2&porPagina=20. Los query params nunca cambian qué recurso es, solo qué parte ves. - Acciones que no encajan como CRUD se modelan como sub-recurso:
POST /api/pedidos/341/ticket("crea una impresión de ticket para este pedido") es más REST quePOST /api/imprimirTicket.
Códigos de estado: el vocabulario de la respuesta
| Código | Significado | Cuándo lo devuelve tu API |
|---|---|---|
200 OK | Todo bien | GET exitoso, PATCH exitoso |
201 Created | Recurso creado | POST exitoso; incluye header Location |
204 No Content | Bien, sin body | DELETE exitoso |
400 Bad Request | El cliente mandó datos inválidos | JSON malformado, falta un campo, cantidad negativa |
401 Unauthorized | No sé quién eres | Falta el token o expiró → el cliente debe re-loguearse |
403 Forbidden | Sé quién eres, pero no puedes | Un mesero intenta borrar un platillo (solo ADMIN) |
404 Not Found | No existe | GET /api/pedidos/99999 |
409 Conflict | Conflicto de estado | Cancelar un pedido que ya está ENTREGADO |
422 Unprocessable | JSON válido, negocio inválido | Pedido sin items (algunos equipos usan 400 para esto; sé consistente) |
500 Internal Server Error | El 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:
- 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.
- JSON es el formato de texto al que ese objeto se convierte para viajar por la red (serialización).
- El body de la request es ese texto JSON ya montado dentro de la petición HTTP, viajando del cliente al servidor.
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:
- Ocultar lo interno: la entidad puede tener campos que el cliente no debe ver (costo del platillo, márgenes, notas internas). El DTO expone exactamente lo público y nada más.
- Desacoplar el contrato: si renombras una columna de la base de datos, el JSON que reciben tus apps no cambia — solo ajustas el mapeo entidad→DTO. Sin DTO, cada cambio interno rompe a todos los clientes.
- Dar forma a cada caso de uso: la lista de pedidos necesita poco (
id, mesa, total, estado); el detalle necesita todo. Un DTO por vista (PedidoResumenDTO,PedidoDetalleDTO) evita mandar datos de más.
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.