From 2312f6ede57f0bcf3beee9b3c96c7a3b93188ae9 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 7 Aug 2026 17:07:16 -0500 Subject: [PATCH 1/2] fix: return 200 for CDA GUI client routes Signed-off-by: Charles Graham, SWT --- .../cda/servlet/SpaErrorStatusFilter.java | 74 +++++++++++++ cwms-data-api/src/main/webapp/WEB-INF/web.xml | 22 +++- .../cda/servlet/SpaErrorStatusFilterTest.java | 104 ++++++++++++++++++ 3 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java diff --git a/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java new file mode 100644 index 000000000..0042e0dca --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java @@ -0,0 +1,74 @@ +package cwms.cda.servlet; + +import java.io.IOException; +import java.util.Set; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Converts the error dispatch used to load known client-side routes into a successful response. + */ +public final class SpaErrorStatusFilter implements Filter { + + // Keep these paths synchronized with cda-gui/src/route-paths.js. + private static final Set SPA_ROUTES = Set.of( + "/data-query", + "/filter-expressions", + "/legacy-format", + "/location-search", + "/regexp", + "/swagger-ui", + "/timestamps", + "/user-lists" + ); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest)request; + HttpServletResponse httpResponse = (HttpServletResponse)response; + + if (isClientRoute(httpRequest)) { + httpResponse.setStatus(HttpServletResponse.SC_OK); + } + + chain.doFilter(request, response); + } + + private boolean isClientRoute(HttpServletRequest request) { + String method = request.getMethod(); + if (!"GET".equalsIgnoreCase(method) && !"HEAD".equalsIgnoreCase(method)) { + return false; + } + + Object errorRequestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI); + if (!(errorRequestUri instanceof String)) { + return false; + } + + String path = removeContextPath((String)errorRequestUri, request.getContextPath()); + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + return SPA_ROUTES.contains(path); + } + + private String removeContextPath(String requestUri, String contextPath) { + if (contextPath == null || contextPath.isEmpty()) { + return requestUri; + } + if (requestUri.equals(contextPath)) { + return "/"; + } + if (requestUri.startsWith(contextPath + "/")) { + return requestUri.substring(contextPath.length()); + } + return requestUri; + } +} diff --git a/cwms-data-api/src/main/webapp/WEB-INF/web.xml b/cwms-data-api/src/main/webapp/WEB-INF/web.xml index 99e5889d3..18ef2c809 100644 --- a/cwms-data-api/src/main/webapp/WEB-INF/web.xml +++ b/cwms-data-api/src/main/webapp/WEB-INF/web.xml @@ -34,12 +34,22 @@ Authorization,Content-Type,Accept,Origin,X-Requested-With - - CorsFilter - /* - - - + + CorsFilter + /* + + + + SpaErrorStatusFilter + cwms.cda.servlet.SpaErrorStatusFilter + + + SpaErrorStatusFilter + /index.html + ERROR + + + 404 /index.html diff --git a/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java new file mode 100644 index 000000000..db51f2b30 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java @@ -0,0 +1,104 @@ +package cwms.cda.servlet; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SpaErrorStatusFilterTest { + + private final SpaErrorStatusFilter filter = new SpaErrorStatusFilter(); + + @ParameterizedTest + @ValueSource(strings = { + "/data-query", + "/filter-expressions", + "/legacy-format", + "/location-search", + "/regexp", + "/swagger-ui", + "/swagger-ui/", + "/timestamps", + "/user-lists" + }) + void returnsOkForClientRoutes(String route) throws ServletException, IOException { + HttpServletRequest request = buildRequest("GET", "/cwms-data" + route); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setStatus(HttpServletResponse.SC_OK); + verify(chain).doFilter(request, response); + } + + @Test + void returnsOkForHeadRequest() throws ServletException, IOException { + HttpServletRequest request = buildRequest("HEAD", "/cwms-data/swagger-ui"); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setStatus(HttpServletResponse.SC_OK); + verify(chain).doFilter(request, response); + } + + @Test + void returnsOkForAlternateContextPath() throws ServletException, IOException { + HttpServletRequest request = buildRequest("GET", "/spk-data/swagger-ui", "/spk-data"); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setStatus(HttpServletResponse.SC_OK); + verify(chain).doFilter(request, response); + } + + @Test + void preservesNotFoundStatusForUnknownRoutes() throws ServletException, IOException { + HttpServletRequest request = buildRequest("GET", "/cwms-data/not-a-client-route"); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response, never()).setStatus(HttpServletResponse.SC_OK); + verify(chain).doFilter(request, response); + } + + @Test + void preservesNotFoundStatusForNonPageRequests() throws ServletException, IOException { + HttpServletRequest request = buildRequest("POST", "/cwms-data/swagger-ui"); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response, never()).setStatus(HttpServletResponse.SC_OK); + verify(chain).doFilter(request, response); + } + + private HttpServletRequest buildRequest(String method, String requestUri) { + return buildRequest(method, requestUri, "/cwms-data"); + } + + private HttpServletRequest buildRequest(String method, String requestUri, String contextPath) { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getMethod()).thenReturn(method); + when(request.getContextPath()).thenReturn(contextPath); + when(request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI)).thenReturn(requestUri); + return request; + } +} From 069ac8ca1f2ba4d4a0de44f60f62dec2229a5b30 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 27 Aug 2026 13:20:13 -0500 Subject: [PATCH 2/2] fix: register SPA status filter with annotation Signed-off-by: Charles Graham, SWT --- .../java/cwms/cda/servlet/SpaErrorStatusFilter.java | 3 +++ cwms-data-api/src/main/webapp/WEB-INF/web.xml | 10 ---------- .../cwms/cda/servlet/SpaErrorStatusFilterTest.java | 11 +++++++++++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java index 0042e0dca..86bcb71ec 100644 --- a/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java +++ b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java @@ -2,18 +2,21 @@ import java.io.IOException; import java.util.Set; +import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.RequestDispatcher; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Converts the error dispatch used to load known client-side routes into a successful response. */ +@WebFilter(urlPatterns = {"/index.html"}, dispatcherTypes = {DispatcherType.ERROR}) public final class SpaErrorStatusFilter implements Filter { // Keep these paths synchronized with cda-gui/src/route-paths.js. diff --git a/cwms-data-api/src/main/webapp/WEB-INF/web.xml b/cwms-data-api/src/main/webapp/WEB-INF/web.xml index 18ef2c809..d037b4ab7 100644 --- a/cwms-data-api/src/main/webapp/WEB-INF/web.xml +++ b/cwms-data-api/src/main/webapp/WEB-INF/web.xml @@ -39,16 +39,6 @@ /* - - SpaErrorStatusFilter - cwms.cda.servlet.SpaErrorStatusFilter - - - SpaErrorStatusFilter - /index.html - ERROR - - 404 diff --git a/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java index db51f2b30..7e6b27e30 100644 --- a/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java @@ -1,14 +1,17 @@ package cwms.cda.servlet; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; +import javax.servlet.DispatcherType; import javax.servlet.FilterChain; import javax.servlet.RequestDispatcher; import javax.servlet.ServletException; +import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; @@ -19,6 +22,14 @@ class SpaErrorStatusFilterTest { private final SpaErrorStatusFilter filter = new SpaErrorStatusFilter(); + @Test + void registersForIndexErrorDispatches() { + WebFilter annotation = SpaErrorStatusFilter.class.getAnnotation(WebFilter.class); + + assertArrayEquals(new String[] {"/index.html"}, annotation.urlPatterns()); + assertArrayEquals(new DispatcherType[] {DispatcherType.ERROR}, annotation.dispatcherTypes()); + } + @ParameterizedTest @ValueSource(strings = { "/data-query",