REST-API-Design-Lab

30 Java-Dateien. 7 zentrale Dateien werden direkt mit echtem Quellcode und ihrem Zusammenspiel erklärt.

Zurück zu Code-Labs

Kapitel 06 · REST API Design

Was dieses Lab zeigt

Zeigt robuste HTTP-Verträge rund um den Order-Use-Case: Validierung, konsistente Fehlerantworten, Idempotenz, Versionierung und die Trennung von API-Modell und Domäne.

Lernziele

  • Stabile API-Verträge definieren
  • Fehler konsistent abbilden
  • Idempotente Requests gestalten

Technik und Schwerpunkte

JDK-Lab30 Java-Dateien1 Tests/RunnerRESTAPI ContractValidation
Echter Quellcode aus diesem Lab

Geführter Codepfad

Der Codepfad folgt einem HTTP-Aufruf durch Controller, Validierung, Application Service, Mapping und Fehlerabbildung. Versionierung, Idempotency und ETag-Konflikte bleiben dabei im selben fachlichen Ablauf sichtbar.

RestApiLabRestOrderControllerRequestValidatorOrderApplicationServiceOrderMapperProblemDetailsFactoryRun5ATestRunner
Lesereihenfolge der zentralen Klassen. Die Pfeile zeigen den didaktischen Weg durch den realen Quellcode, nicht zwingend jeden Laufzeitaufruf.
1. RestApiLabStartet konkrete HTTP-Szenarien und zeigt erfolgreiche sowie fehlerhafte Antworten.
2. RestOrderControllerNimmt Requests entgegen, wendet HTTP-Semantik an und delegiert den Use Case an den Application Service.
3. RequestValidatorPrüft den eingehenden Vertrag und liefert strukturierte Validierungsfehler.
4. OrderApplicationServiceFührt Erzeugung und Änderung der Bestellung unabhängig vom HTTP-Transport aus.
5. OrderMapperTrennt internes Domänenmodell und externes Response-Modell.
6. ProblemDetailsFactoryErzeugt konsistente Problem-Details-Antworten für Validierung, Konflikte und unbekannte Ressourcen.
7. Run5ATestRunnerPrüft Statuscodes, Versionen, Idempotency, ETags und Fehlerverträge.

1. RestApiLab

src/main/java/com/example/restapi/RestApiLab.java
Java-Datei öffnen
Rolle im Ablauf

Startet konkrete HTTP-Szenarien und zeigt erfolgreiche sowie fehlerhafte Antworten.

Im Lesepfad folgt RestOrderController: Nimmt Requests entgegen, wendet HTTP-Semantik an und delegiert den Use Case an den Application Service.

Typ
class RestApiLab
Verwendet
RestOrderController, RequestValidator, OrderApplicationService, OrderMapper, ProblemDetailsFactory
Verwendet von
Run5ATestRunner
Einstiege
newController(), main(String[] args)
package com.example.restapi;

import java.util.Map;

public final class RestApiLab {
    public static RestOrderController newController() {
        OrderMapper mapper = new OrderMapper();
        InMemoryOrderRepository repository = new InMemoryOrderRepository();
        return new RestOrderController(
                new JsonCodec(),
                new RequestValidator(),
                new ProblemDetailsFactory(),
                new OrderApplicationService(repository, mapper),
                new InMemoryIdempotencyStore());
    }

    public static void main(String[] args) {
        RestOrderController controller = newController();
        HttpRequest request = HttpRequest.post("/orders", Map.of(
                "X-Api-Version", "2026-01",
                "X-Correlation-Id", "corr-demo-1",
                "Idempotency-Key", "demo-key-1"),
                "customer=C-100;lines=SKU-1,2,19.99|SKU-2,1,5.00");
        HttpResponse response = controller.handle(request);
        System.out.println("RUN5A_DEMO_STATUS=" + response.status());
        System.out.println(response.body());
    }
}

2. RestOrderController

src/main/java/com/example/restapi/RestOrderController.java
Java-Datei öffnen
Rolle im Ablauf

Nimmt Requests entgegen, wendet HTTP-Semantik an und delegiert den Use Case an den Application Service.

Im Lesepfad folgt RequestValidator: Prüft den eingehenden Vertrag und liefert strukturierte Validierungsfehler.

Typ
class RestOrderController
Verwendet
RequestValidator, OrderApplicationService, ProblemDetailsFactory
Verwendet von
RestApiLab, Run5ATestRunner
Einstiege
handle(HttpRequest request), patchCustomerReference(HttpRequest request, OrderId id, String newCustomerId)
Controller/Primary Adapter
package com.example.restapi;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;

// Pattern: Controller/Primary Adapter - uebersetzt HTTP in Use-Case-Aufrufe und zurueck.
public final class RestOrderController {
    private final JsonCodec json;
    private final RequestValidator validator;
    private final ProblemDetailsFactory problems;
    private final OrderApplicationService service;
    private final IdempotencyStore idempotencyStore;

    public RestOrderController(JsonCodec json, RequestValidator validator, ProblemDetailsFactory problems,
                               OrderApplicationService service, IdempotencyStore idempotencyStore) {
        this.json = json;
        this.validator = validator;
        this.problems = problems;
        this.service = service;
        this.idempotencyStore = idempotencyStore;
    }

    public HttpResponse handle(HttpRequest request) {
        String version = request.header("X-Api-Version").orElse(ApiVersion.V2026_01.headerValue());
        if (!ApiVersion.supported(version)) {
            return problem(problems.unsupportedVersion(request.path(), request.correlationId(), version));
        }
        if (request.method() == HttpMethod.POST && request.path().equals("/orders")) return createOrder(request);
        if (request.method() == HttpMethod.GET && request.path().startsWith("/orders/")) return getOrder(request);
        return HttpResponse.json(404, "{\"error\":\"not found\"}");
    }

    private HttpResponse createOrder(HttpRequest request) {
        String key = request.header("Idempotency-Key").orElse(null);
        if (key == null || key.isBlank()) {
            return problem(problems.validation(request.path(), request.correlationId(),
                    List.of(new ValidationError("Idempotency-Key", "header is required for POST /orders"))));
        }
        String requestHash = sha256(request.body());
        return idempotencyStore.executeOnce(key, requestHash, () -> executeCreate(request));
    }

    private HttpResponse executeCreate(HttpRequest request) {
        CreateOrderRequest dto;
        try {
            dto = json.decodeCreateOrder(request.body());
        } catch (RuntimeException ex) {
            return problem(problems.validation(request.path(), request.correlationId(),
                    List.of(new ValidationError("body", "body cannot be parsed"))));
        }
        List<ValidationError> errors = validator.validate(dto);
        if (!errors.isEmpty()) return problem(problems.validation(request.path(), request.correlationId(), errors));
        try {
            OrderResponse response = service.placeOrder(dto);
            return HttpResponse.json(201, json.encode(response))
                    .withHeader("Location", "/orders/" + response.id())
                    .withHeader("ETag", "W/\"order-" + response.id() + "-v" + response.version() + "\"")
                    .withHeader("X-Correlation-Id", request.correlationId());
        } catch (RuntimeException ex) {
            return problem(problems.conflict(request.path(), request.correlationId(), ex.getMessage()));
        }
    }

    private HttpResponse getOrder(HttpRequest request) {
        String id = request.path().substring("/orders/".length());
        try {
            Order domain = service.getDomain(new OrderId(id));
            OrderResponse response = service.get(new OrderId(id));
            return HttpResponse.json(200, json.encode(response))
                    .withHeader("ETag", ETag.from(domain).value())
                    .withHeader("Cache-Control", "no-store")
                    .withHeader("X-Correlation-Id", request.correlationId());
        } catch (RuntimeException ex) {
            return HttpResponse.json(404, "{\"error\":\"order not found\"}");
        }
    }

    public HttpResponse patchCustomerReference(HttpRequest request, OrderId id, String newCustomerId) {
        try {
            String ifMatch = request.header("If-Match").orElse("");
            OrderResponse response = service.updateCustomerReference(id, newCustomerId, ifMatch);
            return HttpResponse.json(200, json.encode(response));
        } catch (VersionConflictException ex) {
            return problem(problems.preconditionFailed(request.path(), request.correlationId(), ex.expected(), ex.actual()));
        }
    }

    private HttpResponse problem(ProblemDetail detail) {
        return new HttpResponse(detail.status(), Map.of("Content-Type", "application/problem+json"), json.encode(detail));
    }

    private String sha256(String value) {
        try {
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }
}

3. RequestValidator

src/main/java/com/example/restapi/RequestValidator.java
Java-Datei öffnen
Rolle im Ablauf

Prüft den eingehenden Vertrag und liefert strukturierte Validierungsfehler.

Im Lesepfad folgt OrderApplicationService: Führt Erzeugung und Änderung der Bestellung unabhängig vom HTTP-Transport aus.

Typ
class RequestValidator
Verwendet
Verwendet von
RestApiLab, RestOrderController
Einstiege
validate(CreateOrderRequest request)
Validator
package com.example.restapi;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

// Pattern: Validator - technische Eingabepruefung bleibt ausserhalb des Domain-Kerns.
public final class RequestValidator {
    public List<ValidationError> validate(CreateOrderRequest request) {
        List<ValidationError> errors = new ArrayList<>();
        if (request == null) {
            errors.add(new ValidationError("request", "body is required"));
            return errors;
        }
        if (request.customerId() == null || request.customerId().isBlank()) {
            errors.add(new ValidationError("customerId", "customerId is required"));
        }
        if (request.lines() == null || request.lines().isEmpty()) {
            errors.add(new ValidationError("lines", "at least one line is required"));
            return errors;
        }
        for (int i = 0; i < request.lines().size(); i++) {
            CreateOrderLineRequest line = request.lines().get(i);
            String prefix = "lines[" + i + "]";
            if (line.sku() == null || line.sku().isBlank()) errors.add(new ValidationError(prefix + ".sku", "sku is required"));
            if (line.quantity() <= 0) errors.add(new ValidationError(prefix + ".quantity", "quantity must be positive"));
            try {
                if (new BigDecimal(line.unitPrice()).signum() < 0) errors.add(new ValidationError(prefix + ".unitPrice", "unitPrice must not be negative"));
            } catch (RuntimeException ex) {
                errors.add(new ValidationError(prefix + ".unitPrice", "unitPrice must be a decimal number"));
            }
        }
        return errors;
    }
}

4. OrderApplicationService

src/main/java/com/example/restapi/OrderApplicationService.java
Java-Datei öffnen
Rolle im Ablauf

Führt Erzeugung und Änderung der Bestellung unabhängig vom HTTP-Transport aus.

Im Lesepfad folgt OrderMapper: Trennt internes Domänenmodell und externes Response-Modell.

Typ
class OrderApplicationService
Verwendet
OrderMapper
Verwendet von
RestApiLab, RestOrderController
Einstiege
placeOrder(CreateOrderRequest request), get(OrderId id), getDomain(OrderId id), updateCustomerReference(OrderId id, String newCustomerId, String ifMatch)
Application Service
package com.example.restapi;

import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

// Pattern: Application Service - orchestriert Use Cases ohne HTTP-Details im Domain Model.
public final class OrderApplicationService {
    private final OrderRepository repository;
    private final OrderMapper mapper;
    private final AtomicInteger sequence = new AtomicInteger(1000);

    public OrderApplicationService(OrderRepository repository, OrderMapper mapper) {
        this.repository = repository;
        this.mapper = mapper;
    }

    public OrderResponse placeOrder(CreateOrderRequest request) {
        List<OrderLine> lines = request.lines().stream().map(mapper::toDomainLine).toList();
        Order order = Order.accept(new OrderId("O-" + sequence.incrementAndGet()), request.customerId(), lines);
        repository.save(order);
        return mapper.toResponse(order);
    }

    public OrderResponse get(OrderId id) {
        return repository.findById(id).map(mapper::toResponse)
                .orElseThrow(() -> new IllegalArgumentException("order not found"));
    }

    public Order getDomain(OrderId id) {
        return repository.findById(id).orElseThrow(() -> new IllegalArgumentException("order not found"));
    }

    public OrderResponse updateCustomerReference(OrderId id, String newCustomerId, String ifMatch) {
        Order order = getDomain(id);
        String current = ETag.from(order).value();
        if (!current.equals(ifMatch)) {
            throw new VersionConflictException(current, ifMatch);
        }
        order.renameCustomerReference(newCustomerId);
        repository.save(order);
        return mapper.toResponse(order);
    }
}

5. OrderMapper

src/main/java/com/example/restapi/OrderMapper.java
Java-Datei öffnen
Rolle im Ablauf

Trennt internes Domänenmodell und externes Response-Modell.

Im Lesepfad folgt ProblemDetailsFactory: Erzeugt konsistente Problem-Details-Antworten für Validierung, Konflikte und unbekannte Ressourcen.

Typ
class OrderMapper
Verwendet
Verwendet von
RestApiLab, OrderApplicationService
Einstiege
toDomainLine(CreateOrderLineRequest dto), toResponse(Order order)
Mapper
package com.example.restapi;

import java.util.List;

// Pattern: Mapper - trennt API-DTOs und Domain-Objekte bewusst.
public final class OrderMapper {
    public OrderLine toDomainLine(CreateOrderLineRequest dto) {
        return new OrderLine(dto.sku(), dto.quantity(), Money.eur(dto.unitPrice()));
    }

    public OrderResponse toResponse(Order order) {
        List<OrderLineResponse> lines = order.lines().stream()
                .map(line -> new OrderLineResponse(
                        line.sku(),
                        line.quantity(),
                        line.unitPrice().amount().toPlainString(),
                        line.lineTotal().amount().toPlainString()))
                .toList();
        return new OrderResponse(
                order.id().value(),
                order.customerId(),
                order.status().name(),
                order.total().amount().toPlainString(),
                order.version(),
                lines);
    }
}

6. ProblemDetailsFactory

src/main/java/com/example/restapi/ProblemDetailsFactory.java
Java-Datei öffnen
Rolle im Ablauf

Erzeugt konsistente Problem-Details-Antworten für Validierung, Konflikte und unbekannte Ressourcen.

Im Lesepfad folgt Run5ATestRunner: Prüft Statuscodes, Versionen, Idempotency, ETags und Fehlerverträge.

Typ
class ProblemDetailsFactory
Verwendet
Verwendet von
RestApiLab, RestOrderController
Einstiege
validation(String instance, String correlationId, List<ValidationError> errors), conflict(String instance, String correlationId, String detail), preconditionFailed(String instance, String correlationId, String expected, String actual), unsupportedVersion(String instance, String correlationId, String version)
Factory
package com.example.restapi;

import java.util.List;
import java.util.Map;

// Pattern: Factory - zentrale Erzeugung konsistenter Fehlerobjekte.
public final class ProblemDetailsFactory {
    public ProblemDetail validation(String instance, String correlationId, List<ValidationError> errors) {
        return new ProblemDetail(
                "https://errors.example.com/validation-error",
                "Request validation failed",
                400,
                "The request body is syntactically valid JSON but violates API validation rules.",
                instance,
                correlationId,
                errors,
                Map.of("category", "client"));
    }

    public ProblemDetail conflict(String instance, String correlationId, String detail) {
        return new ProblemDetail(
                "https://errors.example.com/business-conflict",
                "Business conflict",
                409,
                detail,
                instance,
                correlationId,
                List.of(),
                Map.of("category", "business"));
    }

    public ProblemDetail preconditionFailed(String instance, String correlationId, String expected, String actual) {
        return new ProblemDetail(
                "https://errors.example.com/precondition-failed",
                "Resource version conflict",
                412,
                "The provided If-Match value does not match the current resource version.",
                instance,
                correlationId,
                List.of(),
                Map.of("expected", expected, "actual", actual));
    }

    public ProblemDetail unsupportedVersion(String instance, String correlationId, String version) {
        return new ProblemDetail(
                "https://errors.example.com/unsupported-api-version",
                "Unsupported API version",
                406,
                "API version " + version + " is not supported by this endpoint.",
                instance,
                correlationId,
                List.of(),
                Map.of("supported", "2026-01"));
    }
}

7. Run5ATestRunner

src/test/java/com/example/restapi/Run5ATestRunner.java
Java-Datei öffnen
Rolle im Ablauf

Prüft Statuscodes, Versionen, Idempotency, ETags und Fehlerverträge.

Damit ist der zentrale Pfad abgeschlossen; der Test-/Runner-Code und die vollständige Dateiliste darunter zeigen die übrigen Varianten.

Typ
class Run5ATestRunner
Verwendet
RestApiLab, RestOrderController
Verwendet von
Einstiege
main(String[] args)
package com.example.restapi;

import java.util.Map;

public final class Run5ATestRunner {
    public static void main(String[] args) {
        createsOrderWithIdempotencyKey();
        replaysSamePostWithoutSecondSideEffect();
        rejectsSameKeyWithDifferentPayload();
        returnsValidationProblem();
        rejectsUnsupportedVersion();
        detectsIfMatchConflict();
        System.out.println("RUN5A_TESTS_OK");
    }

    static void createsOrderWithIdempotencyKey() {
        RestOrderController controller = RestApiLab.newController();
        HttpResponse response = controller.handle(validPost("k-1"));
        assertEquals(201, response.status(), "create status");
        assertTrue(response.headers().containsKey("Location"), "location header");
        assertTrue(response.body().contains("ACCEPTED"), "accepted body");
    }

    static void replaysSamePostWithoutSecondSideEffect() {
        RestOrderController controller = RestApiLab.newController();
        HttpResponse first = controller.handle(validPost("k-2"));
        HttpResponse second = controller.handle(validPost("k-2"));
        assertEquals(201, second.status(), "replay status");
        assertEquals("true", second.headers().get("Idempotency-Replayed"), "replay header");
        assertEquals(first.body(), second.body(), "same body on replay");
    }

    static void rejectsSameKeyWithDifferentPayload() {
        RestOrderController controller = RestApiLab.newController();
        controller.handle(validPost("k-3"));
        HttpRequest differentPayload = HttpRequest.post("/orders", Map.of(
                "X-Api-Version", "2026-01",
                "Idempotency-Key", "k-3"), "customer=C-999;lines=SKU-9,1,1.00");
        HttpResponse response = controller.handle(differentPayload);
        assertEquals(409, response.status(), "same key different payload");
    }

    static void returnsValidationProblem() {
        RestOrderController controller = RestApiLab.newController();
        HttpRequest request = HttpRequest.post("/orders", Map.of(
                "X-Api-Version", "2026-01",
                "Idempotency-Key", "k-4"), "customer=;lines=SKU-1,0,19.99");
        HttpResponse response = controller.handle(request);
        assertEquals(400, response.status(), "validation status");
        assertEquals("application/problem+json", response.headers().get("Content-Type"), "problem content type");
    }

    static void rejectsUnsupportedVersion() {
        RestOrderController controller = RestApiLab.newController();
        HttpRequest request = HttpRequest.post("/orders", Map.of(
                "X-Api-Version", "2030-01",
                "Idempotency-Key", "k-5"), "customer=C-100;lines=SKU-1,1,9.99");
        assertEquals(406, controller.handle(request).status(), "unsupported version");
    }

    static void detectsIfMatchConflict() {
        RestOrderController controller = RestApiLab.newController();
        HttpResponse created = controller.handle(validPost("k-6"));
        String location = created.headers().get("Location");
        String id = location.substring("/orders/".length());
        HttpRequest patch = new HttpRequest(HttpMethod.PATCH, location, Map.of(
                "If-Match", "W/\"order-" + id + "-v999\"",
                "X-Correlation-Id", "test-corr"), Map.of(), "");
        HttpResponse response = controller.patchCustomerReference(patch, new OrderId(id), "C-OTHER");
        assertEquals(412, response.status(), "etag conflict");
    }

    private static HttpRequest validPost(String key) {
        return HttpRequest.post("/orders", Map.of(
                "X-Api-Version", "2026-01",
                "X-Correlation-Id", "corr-" + key,
                "Idempotency-Key", key), "customer=C-100;lines=SKU-1,2,19.99|SKU-2,1,5.00");
    }

    private static void assertEquals(Object expected, Object actual, String message) {
        if (!java.util.Objects.equals(expected, actual)) {
            throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
        }
    }

    private static void assertTrue(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
}
Alle Projektdateien öffnen (33 Einträge)
⌂ Cockpit