CodeGym /Kursy /Spring REST & MVC /Zmiana statusu zadania przez PATCH

Zmiana statusu zadania przez PATCH

Spring REST & MVC
Poziom 25, Lekcja 1
Dostępny

1. Prawidłowy enum a dozwolone przejście

Jeśli wcześniej status zadania wydawał się po prostu jednym z pól, to w praktyce zachowuje się jak mini-cykl życia. Zadanie ma stan, który powinien zmieniać się według reguł, a nie „jak chciał klient w ten wtorek". Inaczej API zamienia się w portal teleportacji: dziś zadanie było TODO, jutro – ARCHIVED, a pojutrze – „dlaczego raport się nie zgadza".

Sam prawidłowy URI tutaj nie wystarczy. Dopóki nie ustali się pełnego zestawu statusów i dozwolonych par przejść, PATCH pozostaje pięknym pomysłem bez klarownych reguł.

Kluczowa myśl wykładu jest prosta: klient może przysłać poprawną wartość TaskStatus (czyli JSON normalnie się sparsował, enum istnieje, Bean Validation nie narzeka), ale przejście może być wciąż zakazane przez logikę przedmiotową. To już nie jest „złe wejście", a konflikt z regułami domeny. W terminach HTTP to najczęściej 409 Conflict.

Żeby poczuć różnicę, wyobraź sobie system dostawy. Status „DOSTARCZONO" jest prawidłowy. Ale jeśli przesyłka jest teraz „PRZYGOTOWANA NA MAGAZYNIE", bezpośredni skok do „DOSTARCZONO" wygląda jak magia. W prawdziwym biznesie magia jest zakazana (poza magią w Springu – ale to inny wykład).

2. Kontrakt PATCH /api/v1/tasks/{taskId}

Z punktu widzenia zewnętrznego API nie wprowadzamy nowego endpointu w stylu /changeStatus. Pozostajemy w granicach zasobu Task i mówimy: status to część zmienialnego stanu zadania, a więc zmieniamy go przez PATCH /tasks/{taskId} tym samym mechanizmem co inne częściowe zmiany.

Żądanie wygląda maksymalnie „po ludzku": wysyłamy tylko to, co chcemy zmienić. Jeśli chcemy zmienić tylko status – wysyłamy tylko status. Jeśli chcemy zmienić i opis, i status – wysyłamy oba pola. Żadnych oddzielnych „komendowych" ścieżek.

Przykładowe żądanie .http (warunkowy UUID podstaw ze swoich danych seed):

PATCH http://localhost:8080/api/v1/tasks/7d3d7d9a-1b2c-4e62-9c65-7b3d2a6b0f11
Content-Type: application/json
Accept: application/json

{
  "status": "IN_PROGRESS"
}

Szczęśliwy scenariusz: serwer zwraca 200 OK i zaktualizowaną reprezentację zadania (zwykle TaskDetailsResponse), żeby klient widział, co naprawdę stało się ze statusem.

A jeśli klient przyśle formalnie poprawny status, ale przejście jest zakazane, zwracamy 409 Conflict w formacie application/problem+json (przez ProblemDetail) i z naszym code = INVALID_STATUS_TRANSITION.

Sam model TaskPatchRequest przy tym pozostaje zwykłym DTO: opisuje formę wejścia, ale nie gwarantuje, że „wszystko wolno". DTO – o strukturze, serwis – o znaczeniu.

3. Macierz przejść statusów

Gdy mówimy „dozwolone przejścia", bardzo chce się to schować gdzieś w głębi kodu: parą if w jednym miejscu, parą if w innym… i po tygodniu odkryć, że DONE -> TODO gdzieś jednak przeszło. Dlatego potrzebujemy macierzy przejść: jedno jawne miejsce, gdzie reguły można przeczytać oczami.

Poniżej – pełny roboczy zestaw statusów i dozwolonych przejść dla projektu.

W naszym Task Tracker API reguły są takie (już ustalone w specyfikacji projektu):

Bieżący status Dozwolone następne statusy
TODO IN_PROGRESS, BLOCKED
IN_PROGRESS BLOCKED, DONE
BLOCKED IN_PROGRESS, DONE
DONE ARCHIVED
ARCHIVED (brak przejść)

Można to przedstawić jako mini-automat stanów (bez BPMN, workflow engine i czegoś w stylu „okej, idę na urlop na dwa tygodnie"):

stateDiagram-v2
    TODO --> IN_PROGRESS
    TODO --> BLOCKED
    IN_PROGRESS --> BLOCKED
    IN_PROGRESS --> DONE
    BLOCKED --> IN_PROGRESS
    BLOCKED --> DONE
    DONE --> ARCHIVED

Tu ważna idea: ARCHIVED to finalna stacja. Z niego „wstecz" nie idziemy. Nie dlatego że REST zabrania, a dlatego że biznes zwykle zabrania. Archiwum to jak „przeniesione do /dev/null, ale kulturalnie".

Jeszcze jeden niuans: co zrobić, jeśli klient przysłał ten sam status, który już stoi u zadania? W praktyce wygodniej jest traktować to jako „nic nie zmieniamy", czyli nie uważać za konflikt. Sprawia to, że zachowanie jest bardziej tolerancyjne na powtarzające się wywołania i na „zbędne PATCHe", które czasem robią klienci.

4. Sprawdzenie przejścia w serwisie

Zmiana statusu wygląda jak mała zmiana JSON, i początkujący często robi logiczny skok: „skoro to w PATCH, to sprawdzenie można napisać w kontrolerze". To bardzo kuszące, bo kontroler jest blisko, ręka się ciągnie… ale tak szybko dostajemy fat controller i tracimy główny plus naszej architektury: kontroler – o HTTP, serwis – o domenie.

Sprawdzenie przejścia to przedmiotowa reguła, a nie mechanika webowa. Nie zależy od @RequestBody, ResponseEntity ani Accept. Zależy od bieżącego stanu zadania i pożądanego następnego stanu. Logicznie więc powinna żyć w serwisie domenowym (lub obok niego w oddzielnym komponencie reguł), a na zewnątrz wyrażać się domenowym wyjątkiem.

Przybliżony przepływ jest taki: kontroler odbiera taskId i TaskPatchRequest, przekazuje do serwisu, a serwis albo zwraca zaktualizowane zadanie, albo rzuca wyjątek InvalidStatusTransitionException. I już globalny error layer zamienia to w ProblemDetail z 409 Conflict.

Kontroler przy tym może pozostać prawie „nudny" – i to komplement:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {

    // Serwis – miejsce, gdzie żyje logika domenowa (łącznie z regułami zmiany statusu).
    private final TaskService taskService;

    TaskController(TaskService taskService) {
        // Wstrzykujemy zależność, kontroler sam nic nie „decyduje" o tranzycjach.
        this.taskService = taskService;
    }

    @PatchMapping("/{taskId}")
    TaskDetailsResponse patch(@PathVariable String taskId,
                              @RequestBody TaskPatchRequest request) {
        // Kontroler zajmuje się tylko wejściem/wyjściem HTTP, całą resztą – serwis.
        return taskService.patchTask(taskId, request);
    }
}

Tak, nie ma tu ResponseEntity. To normalne: status 200 nam odpowiada, a błędy i tak zostaną obsłużone centralnie.

5. Implementacja isAllowed dla tranzycji

Same reguły przejść można implementować na różne sposoby. W projekcie edukacyjnym wybieramy to, co jest łatwiejsze do czytania i wyjaśnienia. I tak, czasem to nie jest „najbardziej abstrakcyjne i rozszerzalne", ale jest najbardziej zrozumiałe. W końcu budujemy API, a nie bibliotekę „uniwersalny silnik wszystkich statusów świata".

Wariant 1: switch po bieżącym statusie

To najbardziej bezpośredni, uczciwy i czytelny wariant. Dobrze widać go oczami i idealnie nadaje się, gdy stanów jest niewiele.

import com.example.tasktracker.domain.model.TaskStatus;

final class TaskStatusTransitions {

    boolean isAllowed(TaskStatus current, TaskStatus next) {
        // Sprawdzamy przejście właśnie z current do next.
        // Ważne: poprawność enum już „rozwiązana" na poziomie deserializacji, tutaj – reguła domenowa.
        return switch (current) {
            case TODO -> next == TaskStatus.IN_PROGRESS || next == TaskStatus.BLOCKED;
            case IN_PROGRESS -> next == TaskStatus.BLOCKED || next == TaskStatus.DONE;
            case BLOCKED -> next == TaskStatus.IN_PROGRESS || next == TaskStatus.DONE;
            case DONE -> next == TaskStatus.ARCHIVED;
            // Status finalny: wyjście z archiwum jest zakazane.
            case ARCHIVED -> false;
        };
    }
}

Zwróć uwagę na ważną rzecz: porównujemy tutaj parę current -> next. Nie ważne nam, że next „istnieje jako enum". Ważne nam, czy można tam przejść właśnie z bieżącego stanu.

Wariant 2: tabela przez Map<TaskStatus, Set<TaskStatus>>

Czasem chce się przechowywać reguły jako strukturę danych, żeby wyglądała jak „konfiguracja". W małym systemie też jest ok, po prostu nie trzeba robić z tego „frameworku tranzycji".

import com.example.tasktracker.domain.model.TaskStatus;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;

final class TaskStatusTransitions {

    // EnumMap/EnumSet – szybki i kompaktowy sposób przechowywania reguł dla enum.
    private final Map<TaskStatus, Set<TaskStatus>> rules = new EnumMap<>(TaskStatus.class);

    TaskStatusTransitions() {
        // Jawnie ustalamy macierz przejść w jednym miejscu.
        rules.put(TaskStatus.TODO, EnumSet.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED));
        rules.put(TaskStatus.IN_PROGRESS, EnumSet.of(TaskStatus.BLOCKED, TaskStatus.DONE));
        rules.put(TaskStatus.BLOCKED, EnumSet.of(TaskStatus.IN_PROGRESS, TaskStatus.DONE));
        rules.put(TaskStatus.DONE, EnumSet.of(TaskStatus.ARCHIVED));
        // ARCHIVED – status finalny: dostępnych next-statusów nie ma.
        rules.put(TaskStatus.ARCHIVED, EnumSet.noneOf(TaskStatus.class));
    }
}

Ten wariant jest dobry dlatego, że tabela „czyta się jak macierz". Ale minus też uczciwy: dla początkujących nieco trudniejszy z powodu EnumMap, EnumSet i konstruktora. Dlatego w kontekście edukacyjnym częściej wygrywa switch.

Dla naszego wykładu wystarczy wybrać jedną drogę (wziąłbym switch), ale ważne jest rozumienie, że sens nie jest w składni, a w tym, że reguła powinna być jednolita, jawna i łatwo weryfikowalna.

6. Sprawdzenie przejścia w patchTask

Teraz najważniejsze: jak sprawić, żeby PATCH naprawdę stosował regułę przejścia, a nie po prostu robił task.setStatus(request.status()) „jeśli pole nie jest null".

Dobry algorytm serwisowy zwykle wygląda tak: pobieramy zadanie, sprawdzamy czy istnieje, potem sprawdzamy regułę przejścia (jeśli status jest w żądaniu), i dopiero po tym zmieniamy stan. Ważne jest właśnie to, że kolejność: najpierw sprawdzenie, potem zmiany. Inaczej dostaniesz „zmieniliśmy – i zaraz zrozumieliśmy, że nie można", i zacznie się zabawa na poziomie „a co w pamięci, a co w repozytorium, a co w odpowiedzi".

DTO dla PATCH pozostaje proste:

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.model.TaskStatus;

public record TaskPatchRequest(
        // null oznacza „pole nie przekazano, nie zmieniaj"
        String title,
        // null oznacza „pole nie przekazano, nie zmieniaj"
        String description,
        // null oznacza „statusu nie patchujemy"
        TaskStatus status
) {}

A metoda serwisowa (uproszczona) może wyglądać tak:

import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;
import com.example.tasktracker.domain.exception.TaskNotFoundException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;

class DefaultTaskService {

    private final TaskRepository taskRepository;

    // Reguły tranzycji trzymamy oddzielnie, żeby nie „rozsmarowywać" if-ów po serwisie.
    private final TaskStatusTransitions transitions = new TaskStatusTransitions();

    Task patchTask(String taskId, TaskPatchRequest request) {
        // 1) Najpierw pobieramy zadanie lub uczciwie padamy z logiką 404 (przez wyjątek).
        Task task = taskRepository.findById(taskId)
                .orElseThrow(() -> new TaskNotFoundException(taskId));

        // 2) Jeśli status w PATCH jest – sprawdzamy domenową regułę tranzycji.
        TaskStatus next = request.status();
        if (next != null && next != task.getStatus() && !transitions.isAllowed(task.getStatus(), next)) {
            // Ważne: rzucamy wyjątek PRZED jakimikolwiek zmianami stanu.
            throw new InvalidStatusTransitionException(task.getStatus(), next);
        }

        // 3) Po sprawdzeniu można bezpiecznie zmieniać stan.
        if (next != null && next != task.getStatus()) {
            task.setStatus(next);
        }

        // 4) Zapisujemy już prawidłowy stan.
        return taskRepository.save(task);
    }
}

Tak, linia z if (...) wygląda nieco długo, ale zawiera cały sens: odróżniamy „pole nie przekazano" od „pole przekazano", i odróżniamy „niczego nie zmieniamy" od „naprawdę próbujemy przejść".

W prawdziwym projekcie patchujesz jeszcze title i description, aktualizujesz updatedAt, sprawdzasz, że zarchiwizowanego zadania nie można edytować i tak dalej. Ale dla dzisiejszego tematu kluczowe jest właśnie miejsce sprawdzenia tranzycji.

7. 409 Conflict i ProblemDetail

Bardzo łatwo pomylić dwa podobne scenariusze.

Pierwszy scenariusz: klient przysłał "status": "IN_PROGRES" (literówka). Jackson nie może sparsować wartości do enum. To błąd wejścia, i powinien polecieć do 400 Bad Request (masz to już pokryte przez global handler jako typowy framework-level błąd deserializacji).

Drugi scenariusz: klient przysłał "status": "ARCHIVED", i Jackson świetnie sparsował to do TaskStatus.ARCHIVED. Ale zadanie jest teraz TODO, a TODO -> ARCHIVED jest zakazane. To już nie jest „zepsuty JSON" i nie „nieprawidłowy enum". To konflikt z regułami domeny. Tu uczciwa odpowiedź to 409 Conflict.

Oto jak może wyglądać nasz ProblemDetail (przykładowy JSON):

{
  "type": "about:blank",
  "title": "Invalid status transition",
  "status": 409,
  "detail": "Transition TODO -> ARCHIVED is not allowed.",
  "instance": "/api/v1/tasks/7d3d7d9a-1b2c-4e62-9c65-7b3d2a6b0f11",
  "code": "INVALID_STATUS_TRANSITION",
  "currentStatus": "TODO",
  "nextStatus": "ARCHIVED"
}

Zwróć uwagę: klientowi przydatna jest wiedza nie tylko „nie można", ale i dlaczego nie można. Dlatego dodajemy do problem response szczegóły: bieżący i żądany status. Idealnie pasuje to do rozszerzalności ProblemDetail: standardowe pola pozostają standardowymi, a specyficzne dane kładziemy jako dodatkowe properties.

8. InvalidStatusTransitionException

Teraz zszyjemy dwie części: domenowy wyjątek i jego przekład na ProblemDetail. To ten moment, gdy „logika biznesowa" staje się „kontraktem API": serwis mówi „nie można", a zewnętrzny świat dostaje uzgodniony error response z właściwym statusem.

Domenowy wyjątek

Wyjątek powinien żyć w warstwie domenowej i nie zależeć od Spring MVC. Po prostu opisuje problem.

package com.example.tasktracker.domain.exception;

import com.example.tasktracker.domain.model.TaskStatus;

public class InvalidStatusTransitionException extends RuntimeException {

    // Bieżący status zadania w momencie próby przejścia.
    private final TaskStatus current;

    // Status, do którego próbowano przejść.
    private final TaskStatus next;

    public InvalidStatusTransitionException(TaskStatus current, TaskStatus next) {
        // Komunikat wygodny dla logów i dla detail w ProblemDetail.
        super("Transition " + current + " -> " + next + " is not allowed.");
        this.current = current;
        this.next = next;
    }

    public TaskStatus getCurrent() { return current; }
    public TaskStatus getNext() { return next; }
}

Ten wyjątek nie decyduje, jaki będzie status HTTP. Po prostu stwierdza fakt: przejście jest zakazane.

Handler w @ControllerAdvice

W GlobalExceptionHandler dodajemy @ExceptionHandler. Tworzymy ProblemDetail ze statusem 409, ustawiamy title, przekazujemy code i dodajemy parę właściwości.

import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;

class GlobalExceptionHandler {

    @ExceptionHandler(InvalidStatusTransitionException.class)
    ProblemDetail handleInvalidTransition(InvalidStatusTransitionException ex) {
        // 409 – to „konflikt" z bieżącym stanem zasobu według reguł domeny.
        ProblemDetail problem = ProblemDetail.forStatus(409);

        // Czytelna dla człowieka krótka nazwa błędu.
        problem.setTitle("Invalid status transition");

        // Szczegół – zwykle konkretna informacja o tranzycji (można pokazywać klientowi).
        problem.setDetail(ex.getMessage());

        // Maszynowo-czytelny kod dla frontu/integracji.
        problem.setProperty("code", "INVALID_STATUS_TRANSITION");

        // Dodatkowe pola pomagają klientowi zrozumieć, co dokładnie nie pasuje.
        problem.setProperty("currentStatus", ex.getCurrent().name());
        problem.setProperty("nextStatus", ex.getNext().name());

        return problem;
    }
}

Tak, tutaj zwracamy ProblemDetail bezpośrednio z handlera. To normalny i bardzo czytelny sposób. Jeśli twój GlobalExceptionHandler dziedziczy po ResponseEntityExceptionHandler, też nie przeszkadza: metoda z @ExceptionHandler żyje obok.

9. Typowe błędy zmiany statusu w PATCH

Błąd nr 1: zwracać 400 Bad Request na zakazane przejście.
To najczęstsze zamieszanie. 400 oznacza, że wejście „zepsuło się" już na poziomie formatu, struktury lub podstawowych ograniczeń. Ale zakazane przejście – to sytuacja „wejście jest zrozumiałe, ale reguły zakazują zmiany". Jeśli oddajesz 400, klientowi staje się niemożliwe odróżnienie „wysłałem śmieci" od „wysłałem normalne dane, ale biznes powiedział nie". Poprawka prosta: na invalid transition – 409 Conflict z oddzielnym code.

Błąd nr 2: trzymać macierz tranzycji w kontrolerze.
Kontroler powinien myśleć o HTTP: jak przyjąć body, jak oddać odpowiedź, jakie nagłówki ustawić. Gdy tylko umieszczasz w nim macierz tranzycji, zamieniasz web-layer w domenowy layer, a potem nagle odkrywasz, że to samo przejście trzeba sprawdzać jeszcze gdzieś indziej. Lepiej trzymać reguły w serwisie (lub w oddzielnym komponencie reguł), a kontroler zostawić cienki.

Błąd nr 3: „rozsmarowywać" reguły tranzycji po kodzie.
Jeśli część reguł sprawdzana jest w jednej metodzie, część w innej, a część „z przyzwyczajenia" w repozytorium, dostajesz sprzeczności. Najbardziej nieprzyjemny bug tutaj wygląda tak: jeden endpoint zabrania DONE -> TODO, a inny przez pomyłkę zezwala. Dlatego reguła powinna być jednolita i scentralizowana: jedna tabela, jeden switch, jedno źródło prawdy.

Błąd nr 4: traktować ARCHIVED jak kolejny zwykły status.
W naszym projekcie archiwum to status finalny. Przejścia z ARCHIVED są zakazane, i ważne jest to nie tylko dla „urody", ale i dla cyklu życia zadania. Gdy zapominasz o tym i pozwalasz na ARCHIVED -> IN_PROGRESS, klienci zaczynają postrzegać archiwum jako „pauzę", a nie finał. Potem pojawiają się wymagania „przywróć z archiwum", „historia przywróceń", „a czemu raporty po archiwum skaczą" – i przypadkowo zbudowałeś połowę Jiry.

Błąd nr 5: zmieniać status przed sprawdzeniem reguły.
Czasem kod pisze się w stylu: „najpierw setStatus, potem if (nieprawidłowo) throw". To niebezpieczne nawet w projekcie in-memory, bo już zmieniłeś obiekt w pamięci. W poważniejszym świecie z persistencją ryzykujesz jeszcze częściowe zapisanie zmian. Prawidłowa kolejność jest nudna, ale niezawodna: najpierw policz, czy można, potem zmieniaj.

1
Zadanie
Spring REST & MVC, poziom 25, lekcja 1
Niedostępne
Macierz dozwolonych przejść statusu
Macierz dozwolonych przejść statusu
1
Zadanie
Spring REST & MVC, poziom 25, lekcja 1
Niedostępne
ProblemDetail dla zabronionego przejścia statusu
ProblemDetail dla zabronionego przejścia statusu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION