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..86bcb71ec --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/servlet/SpaErrorStatusFilter.java @@ -0,0 +1,77 @@ +package cwms.cda.servlet; + +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. + 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..d037b4ab7 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,12 @@ Authorization,Content-Type,Accept,Origin,X-Requested-With - - CorsFilter - /* - - - + + CorsFilter + /* + + + 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..7e6b27e30 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/servlet/SpaErrorStatusFilterTest.java @@ -0,0 +1,115 @@ +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +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", + "/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; + } +}