diff --git a/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java new file mode 100644 index 000000000..71e69bc75 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java @@ -0,0 +1,88 @@ +package com.clearfolio.viewer.config; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +/** + * Publishes the current HTTP API version and rejects explicitly unsupported + * client version requests before controller dispatch. + * + *

Clearfolio's shipped public routes are currently rooted at {@code /api/v1}. + * Existing clients that do not send a version header therefore continue to use + * that path-selected contract. Clients that do send the version header must + * request the exact current version; unsupported values fail closed with a + * controlled error envelope instead of being silently interpreted as v1. + */ +@Component +public final class ApiVersionWebFilter implements WebFilter { + + /** + * Response/request header used for explicit API contract negotiation. + */ + public static final String VERSION_HEADER = "X-Clearfolio-Api-Version"; + + /** + * Version identifier for the currently shipped {@code /api/v1} contract. + */ + public static final String CURRENT_VERSION = "v1"; + + /** + * Creates the stateless API-version negotiation filter. + */ + public ApiVersionWebFilter() { + // No mutable state is required for exact version negotiation. + } + + /** + * Applies version negotiation to public API routes while leaving non-API + * surfaces unchanged. + * + * @param exchange current reactive HTTP exchange + * @param chain remaining WebFlux filter chain + * @return completion signal for the filtered request + */ + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String path = exchange.getRequest().getPath().value(); + if (!path.startsWith("/api/")) { + return chain.filter(exchange); + } + + exchange.getResponse().getHeaders().set(VERSION_HEADER, CURRENT_VERSION); + List requestedVersions = exchange.getRequest().getHeaders().get(VERSION_HEADER); + if (requestedVersions == null) { + return chain.filter(exchange); + } + if (requestedVersions.size() == 1) { + String requestedVersion = requestedVersions.get(0); + if (requestedVersion.isBlank() || CURRENT_VERSION.equals(requestedVersion)) { + return chain.filter(exchange); + } + } + + exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + byte[] body = unsupportedVersionBody(); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body))); + } + + private byte[] unsupportedVersionBody() { + String body = ("{\"errorCode\":\"UNSUPPORTED_API_VERSION\"," + + "\"message\":\"Unsupported API version\"," + + "\"traceId\":\"%s\"," + + "\"details\":{\"supportedVersion\":\"v1\"}," + + "\"code\":\"UNSUPPORTED_API_VERSION\"}") + .formatted(UUID.randomUUID()); + return body.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java new file mode 100644 index 000000000..30695a674 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java @@ -0,0 +1,136 @@ +package com.clearfolio.viewer.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +class ApiVersionWebFilterTest { + + @Test + void nonApiRequestsBypassVersionNegotiation() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/healthz")); + AtomicBoolean invoked = new AtomicBoolean(); + WebFilterChain chain = current -> { + invoked.set(true); + return Mono.empty(); + }; + + filter.filter(exchange, chain).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders()).doesNotContainKey(ApiVersionWebFilter.VERSION_HEADER); + } + + @Test + void missingVersionHeaderUsesCurrentV1Contract() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/v1/convert/jobs")); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + } + + @Test + void explicitCurrentVersionIsAcceptedWithoutChangingRouting() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/viewer/00000000-0000-0000-0000-000000000000") + .header(ApiVersionWebFilter.VERSION_HEADER, ApiVersionWebFilter.CURRENT_VERSION) + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + } + + @Test + void blankVersionHeaderPreservesBackwardCompatibleV1Default() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/convert/jobs") + .header(ApiVersionWebFilter.VERSION_HEADER, " ") + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + } + + @Test + void conflictingDuplicateVersionHeadersFailClosedBeforeControllerDispatch() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/convert/jobs") + .header(ApiVersionWebFilter.VERSION_HEADER, ApiVersionWebFilter.CURRENT_VERSION, "v2") + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isFalse(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + assertThat(exchange.getResponse().getBodyAsString().block()) + .contains("\"errorCode\":\"UNSUPPORTED_API_VERSION\"") + .doesNotContain("v2"); + } + + @Test + void unsupportedVersionFailsClosedBeforeControllerDispatch() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/convert/jobs") + .header(ApiVersionWebFilter.VERSION_HEADER, "v2") + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isFalse(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + assertThat(exchange.getResponse().getHeaders().getContentType().toString()).isEqualTo("application/json"); + String body = exchange.getResponse().getBodyAsString().block(); + assertThat(body) + .contains("\"errorCode\":\"UNSUPPORTED_API_VERSION\"") + .contains("\"code\":\"UNSUPPORTED_API_VERSION\"") + .contains("\"supportedVersion\":\"v1\"") + .doesNotContain("v2"); + } +}