CodeGym /Cursos /Java Server /PUT: reemplazo completo del elemento

PUT: reemplazo completo del elemento

Java Server
Nivel 25, Lección 2
Disponible

1. Rol de PUT y reemplazo completo

PUT es ese momento en que la API empieza a parecerse a un backend adulto, y no a un «formulario para añadir registros». En palabras, «actualización» suena simple, pero en la práctica tiene dos modos: o bien estableces completamente el nuevo estado del recurso, o bien corriges una parte. Y para no mezclar estos dos mundos en una misma papilla, los separamos en diferentes métodos.

En nuestro ReadLater Starter la idea es: PUT /api/v1/reading-list/{id} significa que el cliente ha enviado una «nueva versión de la ficha», y el servidor reemplaza todos los campos modificables del elemento existente. No «añadimos lo que falta», no «dejamos como estaba si no se envió», sino que hacemos un reemplazo honesto. Es como volver a cargar un documento completo: si olvidaste un párrafo, no «se recuperará de la versión anterior», desaparecerá.

Para fijar la diferencia, es útil tener en mente una mini‑tabla:

Método A dónde apunta Significado Qué hay en el body
POST /reading-list a la colección crear un nuevo elemento datos del nuevo elemento
PUT /reading-list/{id} a un recurso concreto reemplazar completamente los datos del recurso conjunto completo de nuevos datos
PATCH /reading-list/{id}/status a un «trozo» concreto del recurso cambiar solo el estado solo el estado

El sentido de PUT también está en que, por su naturaleza, es idempotente: si envías el mismo PUT dos veces, el estado final del recurso será el mismo. Es una propiedad muy «cómoda para el backend»: el cliente puede repetir la solicitud si no está seguro de que haya llegado (por ejemplo, en un fallo de red) sin temor a que el recurso se «duplique».

2. Contrato de PUT /api/v1/reading-list/{id}

Antes de escribir código, vale la pena detenerse un minuto y hablar del contrato como hicimos con POST. Porque si no se habla del contrato, el código saldrá, claro, funcional… pero podría empezar a «actualizar» algo que no es lo que crees.

Nosotros:

- id lo tomamos del path, es decir, de la URI /api/v1/reading-list/{id}. Es un punto clave: la ruta responde a la pregunta «¿qué recurso?», el body responde a «¿cómo debe quedar?».

- body — JSON que se mapea a UpdateReadingItemRequest.

- éxito — 200 OK y respuesta JSON con el recurso actualizado (en forma de ReadingItemResponse).

- no encontrado — 404 Not Found, si no existe un elemento con ese id (y es importante: no creamos uno nuevo «de paso»).

- conflicto — 409 Conflict, si violamos la regla de unicidad de externalId (por ejemplo, intentamos asignar un externalId que ya está ocupado por otro elemento).

- mala solicitud — 400 Bad Request, si id no es un número o el JSON no se pudo parsear (esto no es aún «validación según reglas de negocio», sino corrección básica de la solicitud).

Aquí tienes una tabla compacta de respuestas, para que no tengas en la cabeza «en caso de problemas — bueno, 500»:

Situación Qué ocurrió Qué devolvemos
Todo bien recurso encontrado, reemplazado, guardado 200 OK + JSON del recurso
id no válido /reading-list/abc 400 Bad Request
JSON no válido cuerpo de la solicitud roto 400 Bad Request
No encontrado no existe recurso con ese id 404 Not Found
Conflicto externalId ya está ocupado por otro elemento 409 Conflict

Y un detalle pequeño pero importante: si enviamos JSON, siempre ponemos Content-Type: application/json. De lo contrario, el cliente (incluyendo Postman y luego tus futuros frontends) empezará a adivinar qué le enviaste: JSON, texto, un ensayo filosófico o un grito del alma.

3. UpdateReadingItemRequest: DTO para PUT

Cuando un estudiante ve por primera vez CreateReadingItemRequest y UpdateReadingItemRequest, a menudo surge la idea: «¿Por qué dos clases? ¡Son iguales!» Es una idea normal, pero suele durar exactamente hasta el primer cambio real de requisitos. Y en el mundo del backend los requisitos cambian más o menos tan a menudo como el clima en marzo: por la mañana sol, por la tarde nieve, y para la noche ya estás arreglando un NullPointerException.

Un DTO separado para PUT no se necesita porque «sea bonito», sino porque tienen semánticas diferentes. Create... dice: «crea un nuevo recurso». Update... dice: «aquí está la nueva versión del recurso existente». Incluso si ahora el conjunto de campos coincide, mañana puede aparecer un campo que solo se pueda definir al crear (o al revés, solo al actualizar), y agradecerás no haber atado ambos escenarios a un mismo objeto.

En nuestro proyecto el DTO para PUT se ve así:

package com.example.readlater.readinglist.dto;

import com.example.readlater.readinglist.domain.ReadingStatus;

// DTO para el reemplazo completo del estado del recurso mediante PUT.
// Importante: aquí no hay id — la identidad del recurso se define mediante la URL.
public record UpdateReadingItemRequest(
        // Nuevo título del libro (valor completo, no un "parche")
        String title,
        // Nuevo autor
        String author,
        // Nuevo estado (enum)
        ReadingStatus status,
        // Identificador externo (debe ser único, si se proporciona)
        String externalId,
        // Comentario: en PUT, null significa "borrar explícitamente"
        String comment
) {}

Observa el principio: ningún id en el body. El id es la identidad del recurso y ya está fijado en la URL. Si añades id en el body, abres un portal al infierno: el cliente puede enviar /reading-list/10 y en el body poner "id": 999, y luego tendrás que elegir a quién creerle. Spoiler: en esas situaciones normalmente no le creen a nadie y se van a tomar un té.

4. Reemplazo completo: replaceWith(...)

Ahora un paso importante hacia un código ordenado: ¿dónde ocurrirá exactamente el «reemplazo completo» de los campos? Se puede hacer directamente en el servicio, se pueden distribuir manualmente «cinco asignaciones», se puede intentar hacer un mapeador súper universal con reflexión (no lo hagas), o se puede hacer un método simple y honesto en el objeto de dominio.

¿Por qué es útil? Porque el reemplazo es parte del comportamiento del objeto de dominio. Queremos que la lógica de «qué significa reemplazar un ReadingListItem» viva cerca del propio ReadingListItem, y no esté esparcida entre handlers y services.

Por ejemplo, en ReadingListItem (clase normal, no record) es conveniente añadir un método:

package com.example.readlater.readinglist.domain;

import com.example.readlater.readinglist.dto.UpdateReadingItemRequest;

public class ReadingListItem {
    // campos, constructor, getters...

    public void replaceWith(UpdateReadingItemRequest request) {
        // PUT = reemplazo completo de todos los campos modificables:
        // lo que llegó en el request es en lo que se convierte la entidad.
        this.title = request.title();
        this.author = request.author();
        this.status = request.status();
        this.externalId = request.externalId();
        this.comment = request.comment();
    }
}

Este método literalmente «dice» la semántica: reemplazamos todo el conjunto de campos modificables. Y en el servicio no tendremos la tentación de «bueno, dejemos el comentario anterior si no llegó el nuevo», porque esa magia ya no sería PUT, sino algo más cercano al mundo de patch.

5. Servicio update: 404, 409 y éxito

El servicio es el lugar donde conectamos la semántica del dominio con las reglas de almacenamiento. Es decir, el handler nos trae «hechos HTTP» (id y DTO), y el servicio lo convierte en una operación aplicacional comprensible: «reemplaza los datos del elemento», verificando las restricciones cuando sea necesario.

En el escenario de PUT tenemos tres ramificaciones principales:

1) no hay elemento → 404

2) el elemento existe, pero el id externo entra en conflicto → 409

3) todo bien → reemplazamos, guardamos, devolvemos 200

Para que esta comprobación no surja de la nada, el contrato del repositorio crece aquí con un método más:

public interface ReadingListRepository {
    // ... métodos que ya existían antes

    // Para PUT se necesita una consulta: "¿hay otro elemento con el mismo externalId?"
    boolean existsOtherWithExternalId(String externalId, long currentId);
}

En código podría verse así (usamos Optional para not-found y una excepción para conflicto — simple y claro para el nivel actual del curso):

package com.example.readlater.readinglist.service;

import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.UpdateReadingItemRequest;
import com.example.readlater.readinglist.repository.ReadingListRepository;

import java.util.Optional;

public class ReadingListService {
    private final ReadingListRepository repository;

    public ReadingListService(ReadingListRepository repository) {
        this.repository = repository;
    }

    public Optional<ReadingListItem> update(long id, UpdateReadingItemRequest request) {
        // 1) Primero buscamos el recurso existente.
        // PUT en nuestro contrato no hace upsert.
        Optional<ReadingListItem> existing = repository.findById(id);
        if (existing.isEmpty()) {
            // Recurso no encontrado — el servicio informa al código llamante.
            return Optional.empty();
        }

        // 2) Luego verificamos las restricciones de negocio (unicidad de externalId).
        // Importante: consideramos el id actual para no obtener un "conflicto consigo mismo".
        checkExternalIdConflict(request.externalId(), id);

        // 3) Aplicamos el reemplazo completo de campos.
        ReadingListItem item = existing.get();
        item.replaceWith(request);

        // 4) Guardamos y devolvemos el objeto actualizado.
        return Optional.of(repository.save(item));
    }

    private void checkExternalIdConflict(String externalId, long id) {
        // Si externalId no está definido, no es necesario verificar conflicto.
        if (externalId != null && repository.existsOtherWithExternalId(externalId, id)) {
            // Aquí señalamos "conflicto" mediante una excepción,
            // y la capa web la convertirá en 409 Conflict.
            throw new IllegalStateException("externalId already exists: " + externalId);
        }
    }
}

Aquí hay dos sutilezas que vale la pena sentir con las manos.

Primera: primero buscamos el recurso, y solo después cambiamos algo. Suena obvio, pero en proyectos reales, por las prisas, a veces se escribe código que primero «construye un nuevo objeto» y luego de repente se da cuenta de que no hay nada que actualizar. Esto provoca confusión y entidades innecesarias.

Segunda: la comprobación de conflicto tiene en cuenta el id. De lo contrario caerás en una situación cómica: el recurso ya tiene externalId = OL123, haces un PUT y envías el mismo externalId = OL123, y el servidor dice «¡conflicto!». Formalmente tiene razón (externalId existe), pero aplicacionalmente es falso, porque el externalId pertenece a este mismo objeto. Por eso se necesita un método como existsOtherWithExternalId(externalId, id).

6. externalId: conflicto y 409 Conflict

Vamos un poco más humano: ¿qué es externalId en nuestro dominio? Es una referencia a un catálogo externo del libro. Y acordamos que si un usuario añadió un libro con un externalId concreto, no queremos almacenar por segunda vez el mismo libro (con el mismo externalId) como un elemento separado de la lista; de lo contrario, la reading list se convierte rápidamente en un «almacén de copias».

Por tanto, la regla: externalId, si está definido, debe ser único dentro de la lista de lectura.

En PUT esta regla se vuelve especialmente interesante. Porque PUT puede cambiar el externalId de un elemento existente, por lo que debemos comprobar que el nuevo externalId no está ocupado por otro elemento.

Como el método apareció en la interfaz, lo añadimos explícitamente también en la implementación en memoria. Por ejemplo, si tenemos un almacenamiento basado en Map<Long, ReadingListItem>, la comprobación puede ser muy directa:

package com.example.readlater.readinglist.repository;

import com.example.readlater.readinglist.domain.ReadingListItem;

import java.util.Map;

public class InMemoryReadingListRepository implements ReadingListRepository {
    private final Map<Long, ReadingListItem> storage;

    // constructor, findById, save...

    @Override
    public boolean existsOtherWithExternalId(String externalId, long id) {
        // Devuelve true si se encuentra OTRO elemento con el mismo externalId.
        // "Otro" = item.getId() != id (excluimos el recurso actual que se actualiza).
        return storage.values().stream()
                .anyMatch(item -> externalId.equals(item.getExternalId())
                        && item.getId() != id);
    }
}

Y aquí el 409 Conflict es muy lógico: la solicitud es formalmente correcta, el JSON es normal, el recurso existe, pero no podemos aplicar los cambios porque entran en conflicto con el estado actual del sistema (se viola la unicidad). No es un 400 (la solicitud no está «rota»), ni un 404 (el recurso se encontró), y desde luego no es un 500 (no nos caímos). Es un honesto «conflicto de datos» aplicacional.

7. HTTP-handler para PUT: 200/404/409

Ahora pegamos todo en la capa web. Es importante no arrastrar lógica de negocio al handler. El handler debe hacer tres cosas:

1) entender que es un PUT en la ruta adecuada

2) extraer el id y leer el body

3) convertir el resultado del servicio en una respuesta HTTP correcta

A continuación, la continuación del mismo ReadingListHttpHandler, donde ya vive la rama create. Simplemente ahora añadimos la ruta PUT en la misma clase y mantenemos una regla: hacia afuera va ReadingItemResponse, no el objeto de dominio.

Empecemos con una pequeña utilidad: extraer el id de una ruta como /api/v1/reading-list/123. Para un proyecto de aprendizaje esto puede hacerse de forma muy simple: con split:

package com.example.readlater.readinglist.http;

import java.util.OptionalLong;

public final class PathUtil {
    private PathUtil() {}

    public static OptionalLong extractIdAfterPrefix(String path, String prefix) {
        // Comprobamos que la ruta en general coincide con el prefijo esperado.
        if (!path.startsWith(prefix)) {
            return OptionalLong.empty();
        }

        // Recortamos la "cola" después del prefijo: allí debería estar el id.
        String raw = path.substring(prefix.length());
        try {
            // Parseamos el id a long: si no se puede, eso es 400 Bad Request a nivel HTTP.
            return OptionalLong.of(Long.parseLong(raw));
        } catch (NumberFormatException e) {
            return OptionalLong.empty();
        }
    }
}

Después, un fragmento del handler. Mostramos una variante donde dentro de handle(...) reconocemos PUT y lo enviamos a handlePut(...):

package com.example.readlater.readinglist.http;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;

import java.io.IOException;

public class ReadingListHttpHandler implements HttpHandler {
    @Override
    public void handle(HttpExchange exchange) throws IOException {
        // Método HTTP: PUT / GET / POST / ...
        String method = exchange.getRequestMethod();
        // Ruta: /api/v1/reading-list/123
        String path = exchange.getRequestURI().getPath();

        // Enrutamiento muy simple para un proyecto de aprendizaje.
        if ("PUT".equals(method) && path.startsWith("/api/v1/reading-list/")) {
            handlePut(exchange, path);
            return;
        }

        // Si ninguna ruta coincide — 404.
        exchange.sendResponseHeaders(404, -1);
    }

    private void handlePut(HttpExchange exchange, String path) throws IOException {
        // implementamos abajo
    }
}

Ahora la implementación de handlePut(...). Aquí:

- extraemos el id

- leemos el body

- deserializamos a UpdateReadingItemRequest

- llamamos a readingListService.update(...)

- en caso de conflicto atrapamos la excepción y respondemos con 409

package com.example.readlater.readinglist.http;

import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.example.readlater.readinglist.dto.UpdateReadingItemRequest;
import com.example.readlater.readinglist.service.ReadingListService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.OptionalLong;

public class ReadingListHttpHandler {
    private final ObjectMapper objectMapper;
    private final ReadingListService readingListService;
    private final ReadingItemMapper mapper;

    public ReadingListHttpHandler(ObjectMapper objectMapper,
                                  ReadingListService readingListService,
                                  ReadingItemMapper mapper) {
        this.objectMapper = objectMapper;
        this.readingListService = readingListService;
        this.mapper = mapper;
    }

    private void handlePut(HttpExchange exchange, String path) throws IOException {
        // 1) Extraemos el id de la URL. Si no se puede parsear, es 400.
        OptionalLong idOpt = PathUtil.extractIdAfterPrefix(path, "/api/v1/reading-list/");
        if (idOpt.isEmpty()) {
            exchange.sendResponseHeaders(400, -1);
            exchange.close();
            return;
        }

        // 2) Leemos el cuerpo de la solicitud (JSON).
        String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);

        // 3) Deserializamos el JSON a DTO.
        UpdateReadingItemRequest request;
        try {
            request = objectMapper.readValue(body, UpdateReadingItemRequest.class);
        } catch (JsonProcessingException e) {
            // JSON roto o enum mismatch — esto es 400 Bad Request.
            exchange.sendResponseHeaders(400, -1);
            exchange.close();
            return;
        }

        try {
            // 4) Llamamos al servicio: devuelve Optional.empty() (no encontrado),
            // o devuelve el recurso actualizado, o lanza un conflicto.
            Optional<ReadingListItem> updated = readingListService.update(idOpt.getAsLong(), request);
            if (updated.isEmpty()) {
                exchange.sendResponseHeaders(404, -1);
                exchange.close();
                return;
            }

            // 5) Transformamos el objeto de dominio en DTO de respuesta y devolvemos 200.
            ReadingItemResponse response = mapper.toResponse(updated.get());
            byte[] json = objectMapper.writeValueAsBytes(response);

            exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
            exchange.sendResponseHeaders(200, json.length);
            try (var os = exchange.getResponseBody()) {
                os.write(json);
            }

        } catch (IllegalStateException conflict) {
            // Conflicto de negocio (p. ej., externalId único) -> 409 Conflict.
            exchange.sendResponseHeaders(409, -1);
            exchange.close();
        }
    }
}

ReadingItemMapper aquí es un mapper pequeño normal domain → response DTO. Sin magia: simplemente no deja que el modelo de dominio se filtre hacia afuera y mantiene el mismo contrato externo en POST, PUT y PATCH. Y el 400 en este fragmento ahora cubre no solo el «mal id», sino también el JSON roto o un status que no se pudo parsear como enum.

8. PUT sin upsert

Hay una trampa muy popular: «Si PUT llegó a /reading-list/999 y ese elemento no existe, ¡pues… lo creamos!». Esto se llama upsert (update or insert). En algunos sistemas puede estar justificado. Pero en nuestro curso deliberadamente no hacemos upsert, y la razón no es que upsert sea malo, sino que complica el contrato y la forma de pensar del principiante.

Si PUT de repente empieza a crear un recurso, al cliente le resulta difícil predecir el efecto. Envió una actualización y obtuvo una creación. Además, surge la pregunta: ¿de dónde sale el id? ¿El cliente lo inventó? ¿El servidor lo aceptó? ¿Por qué el servidor permitió que el cliente eligiera el id? Esto ya es todo un tema aparte que fácilmente arrastra discusiones sobre identificadores distribuidos, consistencia, conflictos y otras alegrías de la vida.

En nuestro ReadLater Starter la lógica es más simple y honesta: el id lo asigna el servidor en POST, y PUT trabaja solo con lo que ya existe. No se encuentra el recurso → entonces 404. Sin magia, sin «bueno, supusimos lo que querías».

Y sí, esto hace que la API sea predecible. Y la predecibilidad es una superpotencia del backend. (La segunda superpotencia son los logs. Pero eso ya lo hemos cubierto.)

9. Errores típicos en PUT

Error n.º 1: intentar actualizar el recurso usando el id del body en lugar del del path.
Parece «cómodo» al principio, pero rompe el contrato: la URI deja de ser la fuente de la identidad del recurso. Al final aparecen dos id (en la URL y en el JSON) que pueden no coincidir, y de repente te conviertes en el autor de un detective «a quién creerle». En nuestra API el id siempre se toma del path, y el body solo contiene los nuevos datos.

Error n.º 2: convertir PUT en una actualización parcial «según qué campos lleguen».
A veces se escribe lógica del tipo «si el campo llegó, lo actualizamos; si no, dejamos el anterior». Eso ya es otro contrato y otro significado, más cercano al modelo de patch. Como resultado, el cliente no entiende: ¿la ausencia de un campo significa «dejarlo como estaba» o «ponerlo a cero»? Para PUT es mejor mantener un reemplazo completo honesto, y los cambios parciales hacerlos en un PATCH aparte.

Error n.º 3: hacer un upsert oculto: si no se encuentra, lo creamos.
Desde el punto de vista del cliente es un comportamiento muy inesperado: estaba haciendo un update y obtuvo un create. Aparecen un montón de preguntas nuevas sobre el origen del id y sobre lo seguro que es repetir la solicitud. En nuestro curso, la ausencia del recurso en PUT es 404, sin sorpresas.

Error n.º 4: verificar incorrectamente la unicidad del externalId y caer en un «conflicto consigo mismo».
Si la verificación se hace como existsByExternalId(externalId), entonces al actualizar un recurso con el mismo externalId empezará a fallar por conflicto. Se necesita una verificación del tipo «existe otro elemento con ese externalId», es decir, excluyendo el id actual.

Error n.º 5: responder siempre con 200 OK incluso cuando el recurso no existe.
Esto rompe la confianza en la API. El cliente envió una actualización de un elemento inexistente y obtuvo éxito. Pensará que todo se actualizó, y luego se sorprenderá de que con GET no se encuentra nada. Para un id inexistente debe haber un honesto 404.

1
Tarea
Java Server, nivel 25, lección 2
Bloqueada
Sustitución completa del libro mediante PUT
Sustitución completa del libro mediante PUT
1
Tarea
Java Server, nivel 25, lección 2
Bloqueada
Reemplazo completo del elemento wishlist con conflicto por externalId
Reemplazo completo del elemento wishlist con conflicto por externalId
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION