diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java index dc9cf16d00..b8f7d43b42 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java @@ -92,7 +92,8 @@ public CypherModel submitQuery(String cypherQuery, @Nullable Map } catch (Exception e) { LOG.error(String.format("Failed to submit cypher-query: [ %s ], caused by:", cypherQuery), e); - res = CypherModel.failOf(request.getRequestId().toString(), e.getMessage()); + res = CypherModel.failOf(request.getRequestId().toString(), + e.getMessage(), CypherErrorMapper.map(e)); } finally { try { if (client != null) { diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java new file mode 100644 index 0000000000..5d1ee2478d --- /dev/null +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java @@ -0,0 +1,79 @@ +/* + * 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.hugegraph.api.cypher; + +/** + * Classify cypher execution failures into stable error codes and attach + * actionable hints for the most common, cryptic translator messages. + */ +public final class CypherErrorMapper { + + public static final String SYNTAX_ERROR = "HugeGraph.Cypher.SyntaxError"; + public static final String UNSUPPORTED_FEATURE = + "HugeGraph.Cypher.UnsupportedFeature"; + public static final String EXECUTION_ERROR = "HugeGraph.Cypher.ExecutionError"; + + private CypherErrorMapper() { + } + + public static CypherModel.CypherError map(Throwable e) { + String raw = e.getMessage() != null ? e.getMessage() : e.toString(); + // translator/parser exception types indicate a real parse failure even + // when the message is generic; check class name and FULL message + // before the class-prefix strip below removes the evidence + String className = e.getClass().getName(); + String lowerFull = raw.toLowerCase(); + if (className.contains("SyntaxException") + || className.contains("ParseException") + || lowerFull.contains("syntaxexception") + || lowerFull.contains("parseexception") + || lowerFull.contains("no viable alternative") + // openCypher front-end: "Invalid input 'R': expected ..." + || lowerFull.contains("invalid input '") + || lowerFull.contains("mismatched input") + || lowerFull.contains("extraneous input") + || lowerFull.contains("token recognition error")) { + return new CypherModel.CypherError(SYNTAX_ERROR, + raw.replaceFirst( + "^([a-zA-Z0-9_]+\\.)+[A-Za-z0-9_]+(Exception|Error):\\s*", ""), + "Check the Cypher statement near the position reported " + + "in the message"); + } + String message = raw.replaceFirst( + "^([a-zA-Z0-9_]+\\.)+[A-Za-z0-9_]+(Exception|Error):\\s*", ""); + String lower = message.toLowerCase(); + if (lower.contains("undefined vertex label") + || lower.contains("undefined edge label") + || lower.contains("undefined property key")) { + return new CypherModel.CypherError(EXECUTION_ERROR, message, + "Create the schema element via the schema API before " + + "running the query"); + } + if (lower.contains("not defined") || lower.contains("undefined")) { + return new CypherModel.CypherError(SYNTAX_ERROR, message, + "Declare the variable in a MATCH, UNWIND or WITH clause " + + "before referencing it"); + } + if (lower.contains("not supported") || lower.contains("unsupported")) { + return new CypherModel.CypherError(UNSUPPORTED_FEATURE, message, + "This construct is not covered by the built-in translator, " + + "see the Cypher compatibility guide for supported syntax"); + } + return new CypherModel.CypherError(EXECUTION_ERROR, message, ""); + } +} diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java index e7c3900605..6a51a90174 100644 --- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java +++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java @@ -21,6 +21,8 @@ import java.util.List; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; + import io.swagger.v3.oas.annotations.media.Schema; /** @@ -37,6 +39,11 @@ public class CypherModel { @Schema(description = "The query result") public Result result = new Result(); + @Schema(description = "Structured errors, present only on failure so that " + + "older clients keep parsing successful responses") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public List errors = Collections.emptyList(); + public static CypherModel dataOf(String requestId, List data) { CypherModel res = new CypherModel(); res.requestId = requestId; @@ -45,11 +52,15 @@ public static CypherModel dataOf(String requestId, List data) { return res; } - public static CypherModel failOf(String requestId, String message) { + public static CypherModel failOf(String requestId, String message, + CypherError error) { CypherModel res = new CypherModel(); res.requestId = requestId; res.status.code = 400; res.status.message = message; + if (error != null) { + res.errors = Collections.singletonList(error); + } return res; } @@ -77,4 +88,22 @@ private static class Result { public Map meta = Collections.EMPTY_MAP; } + public static class CypherError { + + @Schema(description = "Stable error classification code") + public String code; + + @Schema(description = "The error message") + public String message; + + @Schema(description = "Actionable hint, empty when unavailable") + public String hint = ""; + + public CypherError(String code, String message, String hint) { + this.code = code; + this.message = message; + this.hint = hint; + } + } + } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java index 28d253a5cf..cd2873d32f 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java @@ -34,6 +34,8 @@ import org.junit.Before; import org.junit.Test; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -135,6 +137,49 @@ public void testRelationQuery() { assertColumn(query(cypher), "friend", "peter"); } + @Test + public void testSyntaxErrorHasStructuredErrorWithHint() { + Response r = client().post(PATH, + "MATCH (n:person) RETURN not_defined_var"); + String content = assertResponseStatus(200, r); + assertContains("\"errors\"", content); + assertContains("SyntaxError", content); + assertContains("Declare the variable", content); + } + + @Test + public void testParseErrorIsClassifiedAsSyntaxError() { + Response r = client().post(PATH, "MATCH (n:person RETURN n"); + String content = assertResponseStatus(200, r); + assertContains("\"code\":\"HugeGraph.Cypher.SyntaxError\"", content); + assertContains("Check the Cypher statement", content); + } + + @Test + public void testUndefinedLabelHasSchemaHint() { + Response r = client().post(PATH, "MATCH (n:robot) RETURN n"); + String content = assertResponseStatus(200, r); + assertContains("ExecutionError", content); + assertContains("Create the schema element", content); + } + + @Test + public void testSuccessResponseKeepsLegacyShape() throws Exception { + Response r = client().post(PATH, QUERY); + String content = assertResponseStatus(200, r); + // hugegraph-client RestResult.readObject fails on unknown fields + // (FAIL_ON_UNKNOWN_PROPERTIES defaults to true), so a successful + // response must not carry the new "errors" key + org.junit.Assert.assertFalse("success response must not contain errors", + content.contains("\"errors\"")); + ObjectMapper strict = new ObjectMapper() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + @SuppressWarnings("unchecked") + Map legacy = strict.readValue(content, Map.class); + org.junit.Assert.assertTrue(legacy.containsKey("requestId")); + org.junit.Assert.assertTrue(legacy.containsKey("result")); + } + @Test public void testReturnNodeIdAsPrimitiveValue() { String cypher = "MATCH (n:cypher_person) WHERE n.name = 'marko' " +