From 28a6add068f88690ac84a4794ea1d2258705ae43 Mon Sep 17 00:00:00 2001 From: Weite Dai Date: Mon, 1 Jun 2026 18:11:39 +1000 Subject: [PATCH 1/3] fix: return 404 instead of 500 for unmatched request path --- .../exception/GlobalExceptionHandler.java | 12 ++++++++++++ .../controller/ImageMetadataControllerTest.java | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java b/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java index 59f58af..1cd86e8 100644 --- a/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java +++ b/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Collections; import java.util.List; @@ -36,6 +37,17 @@ public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e ); } + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNoResourceFoundException(NoResourceFoundException ex) { + log.info("No resource found for path: {}", ex.getResourcePath()); + + return new ErrorResponse( + HttpStatus.NOT_FOUND.getReasonPhrase(), + List.of("No endpoint found for path: " + ex.getResourcePath()) + ); + } + @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleArgumentNotValid(MethodArgumentNotValidException ex) { diff --git a/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java b/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java index 61745e3..d5b912f 100644 --- a/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java +++ b/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java @@ -91,4 +91,12 @@ void getLatestArgoDate_ServiceException_ReturnsInternalServerError() throws Exce mockMvc.perform(get("/metadata/latest-dates/argo")) .andExpect(status().isInternalServerError()); } + + @Test + void getLatestRegionDates_TrailingSlash_ReturnsNotFound() throws Exception { + // A trailing slash matches no handler in Spring Boot 3 and must surface as + // 404, not be swallowed by the catch-all Exception handler as 500. + mockMvc.perform(get("/metadata/latest-dates/sixDaySst-sst/")) + .andExpect(status().isNotFound()); + } } From d5ce0347b22096811843e754ba9192f0c337caa8 Mon Sep 17 00:00:00 2001 From: Weite Dai Date: Mon, 1 Jun 2026 19:12:26 +1000 Subject: [PATCH 2/3] feat: normalize trailing-slash request paths to the same handler --- .../TrailingSlashNormalizationFilter.java | 59 +++++++++++++++++ .../TrailingSlashNormalizationFilterTest.java | 66 +++++++++++++++++++ .../ImageMetadataControllerTest.java | 8 +-- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java create mode 100644 src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java diff --git a/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java b/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java new file mode 100644 index 0000000..c163e88 --- /dev/null +++ b/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java @@ -0,0 +1,59 @@ +package au.org.aodn.oceancurrent.configuration; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * Lets a trailing slash resolve to the same handler, e.g. + * {@code /metadata/latest-dates/sixDaySst-sst/} behaves like + * {@code /metadata/latest-dates/sixDaySst-sst}. + * + *

Spring Boot 3 dropped trailing-slash matching by default, but upstream proxies + * (e.g. AWS Amplify) can append one. We strip it by wrapping the request and + * continuing the same chain — no redirect (which could loop if the proxy re-adds + * the slash) and no deprecated path-match config. Running first means routing and + * security both see the trimmed path. Swagger, API-docs and actuator paths are left alone. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TrailingSlashNormalizationFilter extends OncePerRequestFilter { + + // Framework-managed paths whose trailing-slash handling we must not interfere with: + // springdoc UI/docs/webjars and the actuator base-path (management.endpoints.web.base-path). + private static final List EXCLUDED_PREFIXES = + List.of("/swagger-ui", "/v3/api-docs", "/webjars", "/manage"); + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri.length() <= 1 || !uri.endsWith("/")) { + return true; + } + String withinContext = uri.substring(request.getContextPath().length()); + return EXCLUDED_PREFIXES.stream().anyMatch(withinContext::startsWith); + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String trimmedUri = request.getRequestURI().substring(0, request.getRequestURI().length() - 1); + filterChain.doFilter(new HttpServletRequestWrapper(request) { + @Override + public String getRequestURI() { + return trimmedUri; + } + }, response); + } +} diff --git a/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java b/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java new file mode 100644 index 0000000..70546fa --- /dev/null +++ b/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java @@ -0,0 +1,66 @@ +package au.org.aodn.oceancurrent.configuration; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TrailingSlashNormalizationFilterTest { + + private final TrailingSlashNormalizationFilter filter = new TrailingSlashNormalizationFilter(); + + @Test + void trailingSlash_isTrimmedFromWrappedRequest() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/metadata/latest-dates/sixDaySst-sst/"); + + assertEquals("/metadata/latest-dates/sixDaySst-sst", forwarded.getRequestURI()); + } + + @Test + void pathWithoutTrailingSlash_isLeftUnchanged() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/metadata/latest-dates/sixDaySst-sst"); + + assertEquals("/metadata/latest-dates/sixDaySst-sst", forwarded.getRequestURI()); + } + + @Test + void swaggerUiPath_isNotTrimmed() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/swagger-ui/"); + + assertEquals("/swagger-ui/", forwarded.getRequestURI()); + } + + @Test + void apiDocsPath_isNotTrimmed() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/v3/api-docs/"); + + assertEquals("/v3/api-docs/", forwarded.getRequestURI()); + } + + @Test + void actuatorPath_isNotTrimmed() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/manage/health/"); + + assertEquals("/manage/health/", forwarded.getRequestURI()); + } + + @Test + void rootPath_isLeftUnchanged() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/"); + + assertEquals("/", forwarded.getRequestURI()); + } + + private HttpServletRequest filterAndCapture(String requestUri) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + return (HttpServletRequest) chain.getRequest(); + } +} diff --git a/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java b/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java index d5b912f..9325ea4 100644 --- a/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java +++ b/src/test/java/au/org/aodn/oceancurrent/controller/ImageMetadataControllerTest.java @@ -93,10 +93,10 @@ void getLatestArgoDate_ServiceException_ReturnsInternalServerError() throws Exce } @Test - void getLatestRegionDates_TrailingSlash_ReturnsNotFound() throws Exception { - // A trailing slash matches no handler in Spring Boot 3 and must surface as - // 404, not be swallowed by the catch-all Exception handler as 500. - mockMvc.perform(get("/metadata/latest-dates/sixDaySst-sst/")) + void unmatchedPath_ReturnsNotFound() throws Exception { + // A path that matches no handler must surface as 404, not be swallowed by the + // catch-all Exception handler as 500. + mockMvc.perform(get("/metadata/no/such/endpoint")) .andExpect(status().isNotFound()); } } From caca0ba42f7a25faffbc787acd8e3565a3ee2490 Mon Sep 17 00:00:00 2001 From: Weite Dai Date: Tue, 2 Jun 2026 13:35:08 +1000 Subject: [PATCH 3/3] fix: enhance trailing slash handling and improve error response for unmatched paths --- .gitignore | 1 + .../TrailingSlashNormalizationFilter.java | 5 +- .../exception/GlobalExceptionHandler.java | 4 +- .../TrailingSlashNormalizationFilterTest.java | 49 ++++++++++++++++++- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index a933b77..63b3327 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ out/ .env.edge .DS_Store +tmp/ # BlueJ files *.ctxt diff --git a/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java b/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java index c163e88..a71c204 100644 --- a/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java +++ b/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java @@ -36,11 +36,10 @@ public class TrailingSlashNormalizationFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { - String uri = request.getRequestURI(); - if (uri.length() <= 1 || !uri.endsWith("/")) { + String withinContext = request.getRequestURI().substring(request.getContextPath().length()); + if (withinContext.length() <= 1 || !withinContext.endsWith("/")) { return true; } - String withinContext = uri.substring(request.getContextPath().length()); return EXCLUDED_PREFIXES.stream().anyMatch(withinContext::startsWith); } diff --git a/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java b/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java index 1cd86e8..5cf43ef 100644 --- a/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java +++ b/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java @@ -40,11 +40,11 @@ public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e @ExceptionHandler(NoResourceFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNoResourceFoundException(NoResourceFoundException ex) { - log.info("No resource found for path: {}", ex.getResourcePath()); + log.debug("No endpoint found for path: {}", ex.getResourcePath()); return new ErrorResponse( HttpStatus.NOT_FOUND.getReasonPhrase(), - List.of("No endpoint found for path: " + ex.getResourcePath()) + List.of("The requested endpoint does not exist.") ); } diff --git a/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java b/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java index 70546fa..af2bdca 100644 --- a/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java +++ b/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java @@ -6,6 +6,8 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import java.util.Objects; + import static org.junit.jupiter.api.Assertions.assertEquals; class TrailingSlashNormalizationFilterTest { @@ -40,6 +42,29 @@ void apiDocsPath_isNotTrimmed() throws Exception { assertEquals("/v3/api-docs/", forwarded.getRequestURI()); } + @Test + void trailingSlash_onImageListPath_isTrimmed() throws Exception { + HttpServletRequest forwarded = filterAndCapture("/metadata/image-list/sixDaySst-sst/"); + + assertEquals("/metadata/image-list/sixDaySst-sst", forwarded.getRequestURI()); + } + + @Test + void trailingSlash_withQueryString_trimsPathAndKeepsQuery() throws Exception { + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/metadata/image-list/sixDaySst-sst/"); + request.setQueryString("region=NW"); + request.setParameter("region", "NW"); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + HttpServletRequest forwarded = (HttpServletRequest) Objects.requireNonNull(chain.getRequest()); + assertEquals("/metadata/image-list/sixDaySst-sst", forwarded.getRequestURI()); + assertEquals("region=NW", forwarded.getQueryString()); + assertEquals("NW", forwarded.getParameter("region")); + } + @Test void actuatorPath_isNotTrimmed() throws Exception { HttpServletRequest forwarded = filterAndCapture("/manage/health/"); @@ -54,13 +79,35 @@ void rootPath_isLeftUnchanged() throws Exception { assertEquals("/", forwarded.getRequestURI()); } + @Test + void contextRoot_withContextPath_isLeftUnchanged() throws Exception { + // With context-path /api/v1, a request to the context root (/api/v1/) must not be + // trimmed to /api/v1, since within the context that is the root path "/". + HttpServletRequest forwarded = filterAndCaptureWithContext("/api/v1", "/api/v1/"); + + assertEquals("/api/v1/", forwarded.getRequestURI()); + } + + @Test + void trailingSlash_withContextPath_isTrimmed() throws Exception { + HttpServletRequest forwarded = + filterAndCaptureWithContext("/api/v1", "/api/v1/metadata/image-list/sixDaySst-sst/"); + + assertEquals("/api/v1/metadata/image-list/sixDaySst-sst", forwarded.getRequestURI()); + } + private HttpServletRequest filterAndCapture(String requestUri) throws Exception { + return filterAndCaptureWithContext("", requestUri); + } + + private HttpServletRequest filterAndCaptureWithContext(String contextPath, String requestUri) throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setContextPath(contextPath); MockHttpServletResponse response = new MockHttpServletResponse(); MockFilterChain chain = new MockFilterChain(); filter.doFilter(request, response, chain); - return (HttpServletRequest) chain.getRequest(); + return (HttpServletRequest) Objects.requireNonNull(chain.getRequest()); } }