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
new file mode 100644
index 0000000..a71c204
--- /dev/null
+++ b/src/main/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilter.java
@@ -0,0 +1,58 @@
+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 withinContext = request.getRequestURI().substring(request.getContextPath().length());
+ if (withinContext.length() <= 1 || !withinContext.endsWith("/")) {
+ return true;
+ }
+ 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/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java b/src/main/java/au/org/aodn/oceancurrent/exception/GlobalExceptionHandler.java
index 59f58af..5cf43ef 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.debug("No endpoint found for path: {}", ex.getResourcePath());
+
+ return new ErrorResponse(
+ HttpStatus.NOT_FOUND.getReasonPhrase(),
+ List.of("The requested endpoint does not exist.")
+ );
+ }
+
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleArgumentNotValid(MethodArgumentNotValidException ex) {
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..af2bdca
--- /dev/null
+++ b/src/test/java/au/org/aodn/oceancurrent/configuration/TrailingSlashNormalizationFilterTest.java
@@ -0,0 +1,113 @@
+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 java.util.Objects;
+
+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 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/");
+
+ assertEquals("/manage/health/", forwarded.getRequestURI());
+ }
+
+ @Test
+ void rootPath_isLeftUnchanged() throws Exception {
+ HttpServletRequest forwarded = filterAndCapture("/");
+
+ 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) Objects.requireNonNull(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 61745e3..9325ea4 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 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());
+ }
}