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

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


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

commit 5f585250d58a93e652aaa47258a61e59717ccf1c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Sep 3 21:14:51 2026 +0800

    [Cherry-pick to branch-1.3] [#12783] fix(server): move malformed path-param 
handling to a Jersey ExceptionMapper (#12784) (#12877)
    
    **Cherry-pick Information:**
    - Original commit: e4d076acbfa0f66427563f9e782cdb1370a70edd
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: Jerry Shao <[email protected]>
    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 e3825a7706..31f820af32 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -62,6 +62,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;
@@ -165,6 +167,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