This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new e4d076acbf [#12783] fix(server): move malformed path-param handling to 
a Jersey ExceptionMapper (#12784)
e4d076acbf is described below

commit e4d076acbfa0f66427563f9e782cdb1370a70edd
Author: Jerry Shao <[email protected]>
AuthorDate: Thu Sep 3 16:11:52 2026 +0800

    [#12783] fix(server): move malformed path-param handling to a Jersey 
ExceptionMapper (#12784)
    
    ### What changes were proposed in this pull request?
    
    `{version}` on the model version endpoints (and any other typed
    `@PathParam` on
    the metadata API) is matched and converted by Jersey before any resource
    method runs. When a non-numeric value is supplied, that conversion fails
    and
    Jersey's `ServletContainer` calls `HttpServletResponse#sendError`
    directly,
    which falls through to Jetty's default `ErrorHandler` — producing an
    HTML
    error page instead of a Gravitino `ErrorResponse`.
    
    This PR registers two JAX-RS `ExceptionMapper`s, following the existing
    `JsonParseExceptionMapper` pattern:
    
    - `ParamExceptionMapper` maps
    `org.glassfish.jersey.server.ParamException`,
    which Jersey itself throws when a typed `@PathParam`/`@QueryParam`/etc.
    fails to convert. It keeps Jersey's own status per parameter type (404
    for
      `@PathParam`/`@QueryParam`/`@MatrixParam`, 400 for
      `@HeaderParam`/`@CookieParam`/`@FormParam`) and reports the specific
      exception type (e.g. `PathParamException`) plus the parameter name and
      underlying cause in the message.
    - `NotFoundExceptionMapper` maps `javax.ws.rs.NotFoundException`, thrown
    when
      no resource method matches the request URI at all.
    
    An earlier version of this PR instead installed a Jetty-level
    `ErrorHandler`
    as a server-wide fallback. Review found that `WebAppContext` (used
    whenever
    the Web UI is enabled) installs its own `ErrorPageErrorHandler` at the
    context level, which Jetty always resolves before the server-level
    fallback —
    making that approach a no-op in real deployments with the Web UI
    enabled. The
    `ExceptionMapper` approach avoids this entirely: since a mapper returns
    a
    `Response` with an entity, Jersey never calls `sendError`, so no Jetty
    `ErrorHandler` of either kind is ever involved, regardless of how the
    Web
    UI's `WebAppContext` was constructed.
    
    Iceberg (`/iceberg/*`) and Lance (`/lance/*`) run their own
    `JettyServer`
    instances and are out of scope for this PR.
    
    ### Why are the changes needed?
    
    Every other error on the metadata API returns a structured body carrying
    a
    Gravitino error code and type. A caller that receives HTML on one input
    shape
    and JSON on every other has to special-case the parser, and the response
    identifies the servlet container and its version to anyone who sends a
    bad
    value.
    
    Fix: #12783
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. A malformed typed path parameter under `/api/*` (e.g.
    `GET
    /api/metalakes/{ml}/catalogs/{c}/schemas/{s}/models/{m}/versions/abc`)
    now returns a structured JSON `ErrorResponse` — HTTP 404, type
    `PathParamException`, with a message naming the parameter and the
    underlying
    conversion error — instead of Jetty's default HTML error page. An
    unmatched `/api/*` route returns type `NotFoundException`. No other
    endpoint's behavior changes.
    
    ### How was this patch tested?
    
    - Added `TestParamExceptionMapper` and `TestNotFoundExceptionMapper`
    (unit
    tests) covering the exception-to-`ErrorResponse` mapping, including the
      404-vs-400 split across parameter annotation types.
    - `JsonErrorHandlerIT` (integration test against a real running server)
    covers a malformed model version, a malformed model version URI, and an
    unmatched `/api/*` route, asserting a JSON `Content-Type` and a
    parseable
    `ErrorResponse` body. Verified it passes both with the Web UI disabled
    and
      with it enabled (a directory-based dev `WebAppContext`, and manually
    against a real packaged distribution with the `web`/`web-v2` WAR files)
    —
    the scenario that broke the earlier Jetty-`ErrorHandler`-based approach.
    - Ran the full `server` and `server-common` unit test suites; no
    regressions.
    
    ---------
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../integration/test/JsonErrorHandlerIT.java       | 97 ++++++++++++++++++++++
 .../apache/gravitino/server/GravitinoServer.java   |  4 +
 .../server/web/mapper/NotFoundExceptionMapper.java | 44 ++++++++++
 .../server/web/mapper/ParamExceptionMapper.java    | 58 +++++++++++++
 .../web/mapper/TestNotFoundExceptionMapper.java    | 51 ++++++++++++
 .../web/mapper/TestParamExceptionMapper.java       | 71 ++++++++++++++++
 6 files changed, 325 insertions(+)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JsonErrorHandlerIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JsonErrorHandlerIT.java
new file mode 100644
index 0000000000..797f9eda3c
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JsonErrorHandlerIT.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.client.integration.test;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration test verifying that a malformed typed path parameter on the 
metadata API (e.g. a
+ * non-numeric model version) returns the same structured JSON {@link 
ErrorResponse} used by every
+ * other error on the API, instead of Jetty's default HTML error page.
+ *
+ * <p>Jersey fails to convert the path segment into the resource method's 
typed {@code @PathParam}
+ * before any resource method runs, so the metalake/catalog/schema/model in 
the URL need not
+ * actually exist for this failure to occur.
+ */
+public class JsonErrorHandlerIT extends BaseIT {
+
+  private final HttpClient httpClient = HttpClient.newHttpClient();
+
+  @Test
+  public void testMalformedModelVersionReturnsJsonErrorBody() throws Exception 
{
+    HttpResponse<String> response =
+        
sendGet("/api/metalakes/m/catalogs/c/schemas/s/models/mo/versions/abc");
+
+    Assertions.assertEquals(404, response.statusCode());
+    assertJsonNotFoundBody(response, "PathParamException");
+  }
+
+  @Test
+  public void testMalformedModelVersionUriReturnsJsonErrorBody() throws 
Exception {
+    HttpResponse<String> response =
+        
sendGet("/api/metalakes/m/catalogs/c/schemas/s/models/mo/versions/abc/uri");
+
+    Assertions.assertEquals(404, response.statusCode());
+    assertJsonNotFoundBody(response, "PathParamException");
+  }
+
+  @Test
+  public void testUnknownApiRouteStillReturnsJsonErrorBody() throws Exception {
+    // A route that Jersey cannot match at all is a different failure (no 
@PathParam conversion
+    // is even attempted), but it must be covered by the same fix.
+    HttpResponse<String> response = sendGet("/api/v99/nonexistent/route");
+
+    Assertions.assertEquals(404, response.statusCode());
+    assertJsonNotFoundBody(response, "NotFoundException");
+  }
+
+  private HttpResponse<String> sendGet(String path) throws Exception {
+    HttpRequest request =
+        HttpRequest.newBuilder()
+            .uri(new URI("http://localhost:"; + getGravitinoServerPort() + 
path))
+            .GET()
+            .build();
+    return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+  }
+
+  private void assertJsonNotFoundBody(HttpResponse<String> response, String 
expectedType)
+      throws Exception {
+    String contentType = 
response.headers().firstValue("Content-Type").orElse("");
+    Assertions.assertTrue(
+        contentType.contains("application/json"),
+        "Expected a JSON error body, got Content-Type: " + contentType);
+    Assertions.assertFalse(
+        response.body().contains("<html"), "Response body must not be Jetty's 
HTML error page");
+
+    ErrorResponse errorResponse =
+        ObjectMapperProvider.objectMapper().readValue(response.body(), 
ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, 
errorResponse.getCode());
+    Assertions.assertEquals(expectedType, errorResponse.getType());
+    Assertions.assertFalse(errorResponse.getMessage().isEmpty());
+  }
+}
diff --git 
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java 
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index b12308c228..1450124335 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -64,6 +64,8 @@ import 
org.apache.gravitino.server.web.filter.GravitinoInterceptionService;
 import org.apache.gravitino.server.web.mapper.JsonMappingExceptionMapper;
 import org.apache.gravitino.server.web.mapper.JsonParseExceptionMapper;
 import org.apache.gravitino.server.web.mapper.JsonProcessingExceptionMapper;
+import org.apache.gravitino.server.web.mapper.NotFoundExceptionMapper;
+import org.apache.gravitino.server.web.mapper.ParamExceptionMapper;
 import org.apache.gravitino.server.web.ui.WebUIFilter;
 import org.apache.gravitino.stats.StatisticDispatcher;
 import org.apache.gravitino.tag.TagDispatcher;
@@ -170,6 +172,8 @@ public class GravitinoServer extends ResourceConfig {
     register(JsonProcessingExceptionMapper.class);
     register(JsonParseExceptionMapper.class);
     register(JsonMappingExceptionMapper.class);
+    register(ParamExceptionMapper.class);
+    register(NotFoundExceptionMapper.class);
     register(ObjectMapperProvider.class).register(JacksonFeature.class);
     property(CommonProperties.JSON_JACKSON_DISABLED_MODULES, 
"DefaultScalaModule");
 
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/mapper/NotFoundExceptionMapper.java
 
b/server/src/main/java/org/apache/gravitino/server/web/mapper/NotFoundExceptionMapper.java
new file mode 100644
index 0000000000..f3630619e1
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/mapper/NotFoundExceptionMapper.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.web.mapper;
+
+import javax.annotation.Priority;
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.server.web.Utils;
+
+/**
+ * NotFoundExceptionMapper returns a structured JSON error body when Jersey 
cannot match a request
+ * URI to any resource method at all, instead of letting the servlet container 
fall back to Jetty's
+ * default HTML error page.
+ */
+@Priority(1)
+public class NotFoundExceptionMapper implements 
ExceptionMapper<NotFoundException> {
+
+  @Override
+  public Response toResponse(NotFoundException exception) {
+    String message =
+        StringUtils.isBlank(exception.getMessage())
+            ? "Requested resource not found"
+            : exception.getMessage();
+    return Utils.notFound(NotFoundException.class.getSimpleName(), message);
+  }
+}
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/mapper/ParamExceptionMapper.java
 
b/server/src/main/java/org/apache/gravitino/server/web/mapper/ParamExceptionMapper.java
new file mode 100644
index 0000000000..80c578b1e2
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/mapper/ParamExceptionMapper.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.web.mapper;
+
+import javax.annotation.Nullable;
+import javax.annotation.Priority;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import org.apache.gravitino.server.web.Utils;
+import org.glassfish.jersey.server.ParamException;
+
+/**
+ * ParamExceptionMapper returns a structured JSON error body when Jersey fails 
to convert a request
+ * parameter (e.g. a typed {@code @PathParam}) into its declared Java type, 
instead of letting the
+ * servlet container fall back to Jetty's default HTML error page.
+ *
+ * <p>{@link ParamException} is thrown by Jersey itself while binding request 
parameters, before any
+ * resource method runs, so this mapper is the only place such a conversion 
failure can be
+ * intercepted.
+ */
+@Priority(1)
+public class ParamExceptionMapper implements ExceptionMapper<ParamException> {
+
+  @Override
+  public Response toResponse(ParamException exception) {
+    String message =
+        String.format(
+            "Invalid value for %s parameter '%s'%s",
+            exception.getParameterType().getSimpleName(),
+            exception.getParameterName(),
+            causeMessage(exception.getCause()));
+
+    if (exception.getResponse().getStatus() == 
Response.Status.NOT_FOUND.getStatusCode()) {
+      return Utils.notFound(exception.getClass().getSimpleName(), message);
+    }
+    return Utils.illegalArguments(message);
+  }
+
+  private static String causeMessage(@Nullable Throwable cause) {
+    return cause == null || cause.getMessage() == null ? "" : ": " + 
cause.getMessage();
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/mapper/TestNotFoundExceptionMapper.java
 
b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestNotFoundExceptionMapper.java
new file mode 100644
index 0000000000..f8fd02f16f
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestNotFoundExceptionMapper.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.web.mapper;
+
+import javax.ws.rs.NotFoundException;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestNotFoundExceptionMapper {
+
+  private final NotFoundExceptionMapper mapper = new NotFoundExceptionMapper();
+
+  @Test
+  public void testNotFoundExceptionWithoutMessage() {
+    Response response = mapper.toResponse(new NotFoundException());
+    ErrorResponse entity = (ErrorResponse) response.getEntity();
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, entity.getCode());
+    Assertions.assertEquals(NotFoundException.class.getSimpleName(), 
entity.getType());
+    Assertions.assertFalse(entity.getMessage().isEmpty());
+  }
+
+  @Test
+  public void testNotFoundExceptionWithMessage() {
+    Response response = mapper.toResponse(new NotFoundException("no matching 
route"));
+    ErrorResponse entity = (ErrorResponse) response.getEntity();
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals("no matching route", entity.getMessage());
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/mapper/TestParamExceptionMapper.java
 
b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestParamExceptionMapper.java
new file mode 100644
index 0000000000..fa97ae8b12
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestParamExceptionMapper.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.server.web.mapper;
+
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.glassfish.jersey.server.ParamException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestParamExceptionMapper {
+
+  private final ParamExceptionMapper mapper = new ParamExceptionMapper();
+
+  @Test
+  public void testPathParamExceptionReturnsNotFound() {
+    NumberFormatException cause = new NumberFormatException("For input string: 
\"abc\"");
+    ParamException exception = new ParamException.PathParamException(cause, 
"version", null);
+
+    Response response = mapper.toResponse(exception);
+    ErrorResponse entity = (ErrorResponse) response.getEntity();
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, entity.getCode());
+    Assertions.assertTrue(entity.getMessage().contains("version"));
+    Assertions.assertTrue(entity.getMessage().contains("For input string"));
+  }
+
+  @Test
+  public void testQueryParamExceptionReturnsNotFound() {
+    NumberFormatException cause = new NumberFormatException("For input string: 
\"xyz\"");
+    ParamException exception = new ParamException.QueryParamException(cause, 
"limit", null);
+
+    Response response = mapper.toResponse(exception);
+    ErrorResponse entity = (ErrorResponse) response.getEntity();
+
+    Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals(ErrorConstants.NOT_FOUND_CODE, entity.getCode());
+    Assertions.assertTrue(entity.getMessage().contains("limit"));
+  }
+
+  @Test
+  public void testHeaderParamExceptionReturnsIllegalArguments() {
+    NumberFormatException cause = new NumberFormatException("For input string: 
\"nope\"");
+    ParamException exception = new ParamException.HeaderParamException(cause, 
"X-Count", null);
+
+    Response response = mapper.toResponse(exception);
+    ErrorResponse entity = (ErrorResponse) response.getEntity();
+
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
entity.getCode());
+    Assertions.assertTrue(entity.getMessage().contains("X-Count"));
+  }
+}

Reply via email to