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()); + } }