CodeGym /Cursos /Spring REST & MVC /Semántica del endpoint list

Semántica del endpoint list

Spring REST & MVC
Nivel 22, Lección 4
Disponible

1. Semántica: vacío, 404 y 400

Es muy fácil confundir tres escenarios porque externamente todos se parecen a «bueno… algo no funcionó». Pero para una API son historias diferentes, y cada una debe tener su propio estado y su propio formato de respuesta. Imagine una biblioteca: GET /tasks — es «muestra el catálogo», mientras que GET /tasks/{id} — es «dame un libro específico del estante por su número de inventario». Si no hay libros en el catálogo, la biblioteca sigue existiendo. Pero si pides un libro por un número que no existe, eso ya es diferente.

Para un endpoint de colección (en nuestro caso /api/v1/tasks), un resultado vacío significa simplemente que para la solicitud actual no hay elementos. No es «recurso no encontrado», porque el recurso «colección de tareas» como concepto existe siempre: simplemente ahora tiene cero elementos o en la página seleccionada hay cero elementos. Por lo tanto, en una API REST normal, esto sigue siendo 200 OK y un PagedResponse válido, donde items es una lista vacía.

404 Not Found en este mundo es cuando te diriges a un recurso específico y este no existe. Por ejemplo, GET /api/v1/tasks/{taskId} para un taskId inexistente. O cuando accedes a una ruta incorrecta (un endpoint que no existe).

400 Bad Request — es cuando la solicitud no es válida como tal: envías una página negativa, un tamaño de página 0, un size enorme, una ordenación por un campo no permitido, una dirección upsideDown, o page=abc. Y aquí está lo más importante: 400 no es «no se encontró nada», es «la solicitud no es válida».

Para fijar esto no solo con palabras, es útil tener una pequeña matriz en la cabeza:

Situación Ejemplo de solicitud Qué devolvemos Por qué así
Solicitud válida, pero no hay elementos GET /api/v1/tasks?page=10&size=20 200 OK + PagedResponse con items vacío La colección existe, simplemente está vacía en esta página
Recurso no encontrado GET /api/v1/tasks/{taskId} 404 Not Found + ProblemDetail No existe un recurso específico en esa dirección
Solicitud no válida GET /api/v1/tasks?size=0 o
sort=hack,asc
400 Bad Request + ProblemDetail El cliente ha violado el contrato de los parámetros

Observa que en la primera fila de la tabla no escribimos «error», aunque al usuario pueda parecerle molesto ver algo vacío. Pero la API no es para emociones, sino para previsibilidad.

Es útil mantener esta distinción clara desde el principio: el conjunto de parámetros de la lista seguirá creciendo, pero la semántica de los estados de la colección no debe dispersarse.

2. 200 OK e items vacío

Un resultado vacío no es «un caso especial que debe manejarse con una rama separada en el controlador». Es una rama normal de ejecución del endpoint list. Y esto tiene un buen sentido práctico: el cliente puede mostrar un «estado vacío» («aún no hay tareas») sin incluir lógica de emergencia para el manejo de errores. Un error es cuando se viola el contrato, no cuando no hay datos.

En nuestro contrato, el «resultado vacío» debe verse igual de estable que el «no vacío»: el cuerpo de la respuesta sigue siendo el mismo PagedResponse<TaskSummaryResponse>, simplemente items está vacío. Y aquí hay una disciplina importante: items debe ser una lista, no null. null es un regalo al cliente en forma de NPE/Cannot read property 'length' of null y extraños if en el frontend. Una lista vacía es «no tengo nada que mostrar», mientras que null es «no sé qué es este campo».

Hay dos escenarios «vacíos» que es importante distinguir en la mente (pero no necesariamente con diferentes estados):

El primero — colección vacía. Es cuando totalElements == 0, es decir, no hay tareas en absoluto. Entonces items está vacío, totalElements = 0, y totalPages según nuestra regla también será 0. Sí, algunos hacen totalPages=1 incluso con cero elementos, pero eso convierte los metadatos en filosofía. En nuestro proyecto de aprendizaje, la lógica es más simple: «no hay páginas porque no hay elementos».

{
  "items": [],
  "page": 0,
  "size": 20,
  "totalElements": 0,
  "totalPages": 0,
  "sort": "updatedAt,desc"
}

El segundo — página vacía con colección no vacía. Es cuando hay elementos en general, pero la página seleccionada está «más allá del horizonte». Por ejemplo, tenemos solo 7 tareas, size=5, entonces hay 2 páginas (0 y 1), pero el cliente solicitó page=10. Esto no es un error de contrato: page no viola el rango (no hemos introducido un max page). La solicitud es válida, solo que el resultado está vacío.

{
  "items": [],
  "page": 10,
  "size": 5,
  "totalElements": 7,
  "totalPages": 2,
  "sort": "updatedAt,desc"
}

¿Por qué es útil esto? Porque el cliente puede entender honestamente la situación sin adivinanzas. Ve: totalPages=2, page=10 — significa que solicitó demasiado lejos y debe dejar de pasar páginas. Si en su lugar devolviéramos 404 o 400, el cliente tendría que decidir si es un «error», «no hay datos» o «ruta no encontrada».

Es especialmente importante que la respuesta refleje los valores realmente aplicados de page, size y sort. Si el cliente no envió sort y nosotros aplicamos el valor por defecto, entonces en la respuesta debe aparecer sort: "updatedAt,desc". Esto no es «duplicación», es una forma de eliminar ambigüedades: el cliente ve no solo los datos, sino también el contexto de las reglas con las que se obtuvieron.

3. 400 Bad Request para page/size/sort

Un resultado vacío trata sobre los datos. 400 Bad Request trata sobre el contrato. Y aquí es donde comienzan los errores favoritos de los principiantes: «vamos a ajustar los parámetros de entrada silenciosamente y fingir que todo está bien». Por ejemplo, si el cliente envía size=1000, el servidor silenciosamente cambia a size=100 para «no enfadarse». Suena amigable, pero en realidad estás creando magia oculta, por la cual el cliente no entiende que está haciendo algo mal, y luego esto se manifiesta en errores del tipo «a veces no devuelve la cantidad correcta de elementos».

Acordamos una regla simple: un parámetro ausente es motivo para tomar el valor por defecto. Un parámetro no válido es motivo para devolver 400 a través de nuestro contrato de error existente basado en ProblemDetail.

En la entrada del endpoint list ya existe un TaskSearchCriteria explícito: page solo admite valores >= 0, size está en el rango 1..100, y sort pasa por una validación separada de formato y lista blanca. Aquí es importante no repetir todo el código una vez más, sino mantener el significado: null significa «el parámetro no se ha enviado», y cualquier violación del rango o de la lista blanca es una entrada no válida. Este DTO puede soportar fácilmente la ampliación de la lista con nuevos criterios de filtrado: cambia el contenido del criteria, pero no la lógica de estado del endpoint.

Ahora los escenarios se ven así. Si page no se ha enviado, es null, y nuestra normalización lo convierte tranquilamente en 0. Si page=-1, esto ya es una violación del contrato y debe derivar en 400. Si size=0 — también 400. Si size=1000 — 400. No «arreglamos» nada en silencio, porque el contrato debe ser claro.

Con sort es un poco más interesante: Bean Validation no lo resuelve todo, porque el formato field,dir lo analizamos manualmente, y la lista blanca también la verificamos manualmente. Por lo tanto, un error del tipo «campo de ordenación no soportado» lo convertimos en nuestra excepción de dominio «entrada no válida» (por ejemplo, InvalidInputException) y luego devolvemos ProblemDetail a través de @ControllerAdvice. Un campo desconocido, una dirección incorrecta o un formato roto son 400, no una lista vacía ni un fallback silencioso al valor por defecto.

La idea clave de esta lección: un error en sort no es «no se encontró nada» ni «pondremos el valor por defecto y callaremos». Es una solicitud no válida, y debe llevar a un 400 en un formato de error unificado.

Además, es importante recordar los tipos. Si el cliente envía page=abc, entonces no es que «no haya pasado @Min», porque puede que ni siquiera se llegue a Bean Validation: Spring primero intenta convertir el parámetro de consulta en un número, y esto falla en la etapa de binding/type conversion. Pero desde el punto de vista del contrato externo, sigue siendo una entrada no válida. Por lo tanto, nuestra capa global de errores debe convertir tanto los errores de validación como los de type mismatch y los de «sort no soportado» en un ProblemDetail predecible.

Ejemplo de respuesta (simplificada, pero correcta en cuanto al significado) para size=0:

{
  "type": "about:blank",
  "title": "Invalid request",
  "status": 400,
  "detail": "Validation failed",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT",
  "fieldErrors": [
    {
      "field": "size",
      "message": "must be greater than or equal to 1"
    }
  ]
}

Y un ejemplo para «campo de ordenación no soportado»:

{
  "type": "about:blank",
  "title": "Invalid request",
  "status": 400,
  "detail": "Unsupported sort field: hackerField",
  "instance": "/api/v1/tasks",
  "code": "INVALID_INPUT"
}

Observa cómo esto ayuda al cliente. En el primer caso, puede resaltar el campo específico size. En el segundo, puede mostrar al usuario «no se puede ordenar por este campo» o corregir la interfaz (por ejemplo, eliminar el campo prohibido de la lista). Si devolviéramos 200 y una lista vacía, el cliente no entendería nada en absoluto.

4. Ejemplos .http para 200/400/404

Cuando diseñas la semántica, es muy útil verificarla no solo con los ojos del código, sino también con los ojos del cliente. En nuestro proyecto, es conveniente hacerlo a través de archivos .http (o Postman), porque allí se ve claramente: solicitud → estado → cuerpo. E inmediatamente queda claro dónde tienes «todo igual y confuso» y dónde el cliente realmente recibe una señal.

Aquí hay un ejemplo de una solicitud válida a una página «lejana». Debe ser exitosa, pero con items vacío:

### Página vacía: la solicitud es válida, los items están vacíos
GET http://localhost:8080/api/v1/tasks?page=10&size=5&sort=updatedAt,desc
Accept: application/json

# Esperamos: 200 OK (incluso si items = [])

Si existen tareas, verás 200 OK e items: [], además de los valores honestos de totalElements/totalPages. Si no hay tareas en absoluto, igualmente verás 200 OK, solo que totalElements será 0.

Ahora, un ejemplo de tamaño de página no válido. Esto ya no es «vacío», sino «contrato roto»:

### Tamaño no válido: debe ser 1..100
GET http://localhost:8080/api/v1/tasks?page=0&size=0&sort=updatedAt,desc
Accept: application/json

# Esperamos: 400 Bad Request + ProblemDetail con detalles sobre el campo size

Esperamos 400 Bad Request y Content-Type: application/problem+json (o un formato equivalente de Problem Details), y dentro, tu code=INVALID_INPUT y los detalles.

Y el tercer ejemplo: un error en sort. Aquí es especialmente importante no «tragarse» el error tipográfico, porque de lo contrario el cliente estará años ordenando aleatoriamente «como salga».

### Campo de ordenación no válido: no está en la lista blanca
GET http://localhost:8080/api/v1/tasks?page=0&size=20&sort=hackerField,asc
Accept: application/json

# Esperamos: 400 Bad Request, porque sort viola la lista blanca

Esperamos 400 Bad Request, y en detail — un mensaje claro de que el campo no está soportado.

Así es exactamente como debe funcionar una colección madura: el conjunto de parámetros puede crecer, pero la distinción entre 200/400/404 sigue siendo la misma.

Para enfatizar aún más la diferencia con 404, es útil compararlo con el endpoint de detalle:

### Recurso individual no encontrado
GET http://localhost:8080/api/v1/tasks/00000000-0000-0000-0000-000000000000
Accept: application/json

# Esperamos: 404 Not Found, porque nos dirigimos a un recurso específico por id

Esto ya es 404 Not Found, porque se accede a un recurso de tarea concreto. Y esta es una clase de escenario diferente a «la lista está vacía».

Si mantienes estas cuatro solicitudes juntas, en tu cabeza se fijará muy rápidamente la lógica correcta: el endpoint de colección siempre devuelve 200 ante una solicitud válida, incluso si «no hay nada»; 400 solo por errores de contrato en la solicitud; 404 por la ausencia de un recurso específico.

5. Errores típicos en la semántica de list

En este tema, los errores son insidiosos porque rara vez se ven como «la aplicación se ha caído». Normalmente todo funciona, solo que el cliente vive en un mundo de malentendidos. Por lo tanto, es mejor detectar estos momentos ahora, mientras el proyecto es de aprendizaje, y no cuando tengas tres clientes, cinco integraciones y un product manager muy nervioso.

Error nº1: devolver 404 Not Found para una lista vacía.
Esta es la confusión más popular. 404 significa que en esa URI no hay recurso. Pero /api/v1/tasks es un recurso de colección, siempre existe. Si no hay tareas, no es que «el recurso no se haya encontrado», es que «la colección está vacía». Al cliente, un 404 solo le empeorará las cosas, porque activará un escenario de emergencia en lugar de un estado vacío.

Error nº2: devolver 204 No Content para una lista vacía.
A veces parece lógico: «no hay nada, entonces no hay contenido». Pero nuestro contrato de lista es un PagedResponse, e incluso con items vacío, el cliente necesita los metadatos (page, size, totalPages, sort). 204 prohíbe el cuerpo de la respuesta por definición, lo que significa que rompes tu propio contrato y obligas al cliente a adivinar.

Error nº3: corregir silenciosamente size/sort no válidos en lugar de devolver 400.
Si el cliente envía size=1000 y el servidor lo «corrige» a 100, el cliente seguirá enviando 1000. Lo mismo con sort: un fallback silencioso al valor por defecto convierte un error tipográfico en un «modo de funcionamiento oculto». Al cabo de un mes, nadie recuerda por qué los resultados «no se ordenan como deberían». Es mucho más honesto y útil devolver 400 y obligar al cliente a cumplir con el contrato.

Error nº4: hacer items = null en lugar de una lista vacía.
Incluso si en Java te resulta más fácil devolver null, para el contrato JSON esto es casi siempre un error. El cliente se verá obligado a escribir comprobaciones adicionales, y en algunos lenguajes/frameworks esto incluso provocará fallos. Una lista vacía es una forma normal de los datos, y debe ser estable.

Error nº5: calcular totalElements basándose en el tamaño de la página actual.
Esto rompe el sentido de la paginación. Los metadatos deben describir el conjunto completo de resultados, no solo el fragmento actual. De lo contrario, el cliente no podrá saber cuántas páginas hay en total ni podrá construir una navegación normal. En nuestro algoritmo, totalElements se obtiene de la colección completa (ordenada), y items del slice.

1
Tarea
Spring REST & MVC, nivel 22, lección 4
Bloqueada
Página vacía, `400` y `404` para la lista de eventos
Página vacía, `400` y `404` para la lista de eventos
1
Tarea
Spring REST & MVC, nivel 22, lección 4
Bloqueada
`sort` incorrecto no es igual a resultado vacío
`sort` incorrecto no es igual a resultado vacío
1
Cuestionario/control
Paginación de API, nivel 22, lección 4
No disponible
Paginación de API
Listas, páginas y ordenación
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION