Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions docs/cypher-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Cypher API response compatibility

The Cypher endpoint is
`/graphspaces/{graphspace}/graphs/{graph}/cypher`. GET accepts the `cypher`
query argument; POST accepts a raw Cypher statement with
`Content-Type: application/json`. Submit one statement per request.
The Apache mainline endpoint does not accept a parameter map. In the default
translator, unbound `$name` expressions become Cypher null; they do not raise a
missing-parameter error. Do not send `?parameters=...` to bind them.

## Response envelope

Responses retain the Gremlin-compatible top-level fields `requestId`, `status`
and `result`. There is no top-level `errors` field. Existing Java clients can
continue to deserialize both successful and failed queries without an upgrade.

A successful query has `status.code = 200`, an empty `status.attributes` map,
and its rows in `result.data`, for example:

```json
{
"requestId": "example-request",
"status": {"message": "", "code": 200, "attributes": {}},
"result": {"data": [{"value": 1}], "meta": {}}
}
```

When query submission or execution fails inside the Cypher client, the existing
HTTP 200 / `status.code = 400` convention and `status.message` are preserved.
Authentication, request validation and other HTTP-layer errors remain separate
and need not use this envelope. Consumers must inspect `status.code` instead of
relying only on the HTTP status.

Structured error metadata lives at `status.attributes.errors`, an array with
one entry for the failed query. Each entry contains a stable string `code`, the
translator/execution `message`, and a `hint` (empty when no specific guidance is
available). Message wording is not a stable contract. Example:

```json
{
"requestId": "example-request",
"status": {
"message": "Expected exactly one statement per query but got: 2",
"code": 400,
"attributes": {
"errors": [{
"code": "HugeGraph.Cypher.SyntaxError",
"message": "Expected exactly one statement per query but got: 2",
"hint": "Submit exactly one Cypher statement per request"
}]
}
},
"result": {"data": null, "meta": {}}
}
```

Clients that only consume `status.code`, `status.message` and `result` can ignore
the attributes. A failure with no mapped metadata may have empty attributes.

## Error codes and hints

| Code | Recognized failure | Hint |
| --- | --- | --- |
| `HugeGraph.Cypher.SyntaxError` | Parser invalid input or unknown function | Check the query syntax and function names supported by the built-in Cypher translator |
| `HugeGraph.Cypher.SyntaxError` | More or fewer than one statement | Submit exactly one Cypher statement per request |
| `HugeGraph.Cypher.SyntaxError` | `Variable` followed by a backtick-quoted name and `not defined` | Declare the variable in a MATCH, UNWIND or WITH clause before referencing it |
| `HugeGraph.Cypher.ExecutionError` | Undefined vertex label, edge label, property key or index label | Create the schema element via the schema API before running the query |
| `HugeGraph.Cypher.UnsupportedFeature` | Translator reports `not supported` or `unsupported` | This construct is not covered by the built-in translator, see the Cypher compatibility guide for supported syntax |
| `HugeGraph.Cypher.ExecutionError` | Other failures | Empty string |

The Gremlin transport exposes parser failures as messages, so classification
recognizes the translation-1.0.4 message forms above. Other failures fall back to
`ExecutionError`; these codes are not a promise to identify every possible
translator failure. There is no `HugeGraph.Cypher.MissingParameter` contract.

This contract repairs the unreleased proposal in
[Server #3241](https://github.com/apache/hugegraph/pull/3241). The paired website
work is [Doc #499](https://github.com/apache/hugegraph-doc/pull/499); its author
must incorporate this response contract before coordinated publication.
The optional [bound-query fork #238](https://github.com/hugegraph/hugegraph/pull/238)
is independent and is not implemented here.

## Regression checks

Run the mapper, real-parser and envelope tests from the repository root:

```sh
mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test \
-Dtest=CypherErrorTest -Dsurefire.failIfNoSpecifiedTests=false
```

`CypherApiTest` also exercises malformed syntax, multiple statements, undefined
variables and missing schema through a running test server. Follow the normal
Server API-test setup using disposable test data.

The standalone `CypherClientCompatibilityTest` uses the actual Java Client
`Response` and `RestResult` classes. It is outside the Server reactor's source
roots to avoid a Server-to-Client dependency cycle. Supply an existing Client
jar (checked with locally available 1.7.0 and development 1.8.0 artifacts) and run from the repository root:

```sh
mvn -f hugegraph-server/hugegraph-api/pom.xml dependency:build-classpath \
-Dmdep.outputFile="$PWD/target/cypher-classpath.txt"
CYPHER_CLIENT_JAR="$HOME/.m2/repository/org/apache/hugegraph/hugegraph-client/1.7.0/hugegraph-client-1.7.0.jar"
CYPHER_CP="$CYPHER_CLIENT_JAR:$(cat target/cypher-classpath.txt)"
mkdir -p target/cypher-compatibility
javac -encoding UTF-8 -proc:none -cp "$CYPHER_CP" \
-d target/cypher-compatibility \
hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java \
hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java \
hugegraph-server/hugegraph-test/src/compatibility/java/CypherClientCompatibilityTest.java
java -cp "target/cypher-compatibility:$CYPHER_CP" \
org.junit.runner.JUnitCore CypherClientCompatibilityTest
```

These checks cover legacy envelopes, current success/failure deserialization,
status/message preservation, and a negative control proving that a top-level
`errors` field is rejected. They do not certify a live Hubble deployment or
matching-release-candidate acceptance.
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ public CypherModel submitQuery(String cypherQuery, @Nullable Map<String, String>
} 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 {
client.close();
cluster.close();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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;

import java.util.Locale;
import java.util.regex.Pattern;

/**
* 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 static final Pattern UNDEFINED_VARIABLE =
Pattern.compile("variable `[^`]+` not defined");

private CypherErrorMapper() {
}

public static CypherModel.CypherError map(Throwable e) {
String message = e.getMessage() != null ? e.getMessage() : e.toString();
// strip noisy exception class prefixes, e.g. "...driver.exception.ResponseException: "
message = message.replaceFirst(
"^([a-zA-Z0-9_]+\\.)+[A-Za-z0-9_]+(Exception|Error):\\s*", "");
String lower = message.toLowerCase(Locale.ROOT);

if (lower.contains("undefined vertex label")
|| lower.contains("undefined edge label")
|| lower.contains("undefined property key")
|| lower.contains("undefined index label")) {
return new CypherModel.CypherError(EXECUTION_ERROR, message,
"Create the schema element via the schema API before " +
"running the query");
}
if (UNDEFINED_VARIABLE.matcher(lower).find()) {
return new CypherModel.CypherError(SYNTAX_ERROR, message,
"Declare the variable in a MATCH, UNWIND or WITH clause " +
"before referencing it");
}
if (lower.contains("invalid input") || lower.contains("unknown function")) {
return new CypherModel.CypherError(SYNTAX_ERROR, message,
"Check the query syntax and function names supported by " +
"the built-in Cypher translator");
}
if (lower.contains("expected exactly one statement")) {
return new CypherModel.CypherError(SYNTAX_ERROR, message,
"Submit exactly one Cypher statement per request");
}
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, "");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,16 @@ public static CypherModel dataOf(String requestId, List<Object> 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.status.attributes = Collections.singletonMap(
"errors", Collections.singletonList(error));
}
return res;
}

Expand Down Expand Up @@ -77,4 +82,22 @@ private static class Result {
public Map<String, Object> 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;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* 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.
*/

import java.util.Collections;

import org.apache.hugegraph.api.cypher.CypherErrorMapper;
import org.apache.hugegraph.api.cypher.CypherModel;
import org.apache.hugegraph.rest.RestResult;
import org.apache.hugegraph.structure.gremlin.Response;
import org.junit.Assert;
import org.junit.Test;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

/**
* Run with an existing hugegraph-client jar (see docs/cypher-api.md).
* Kept outside the Server reactor to avoid a Server -> Client dependency cycle.
*/
public class CypherClientCompatibilityTest {

private static final ObjectMapper JSON = new ObjectMapper();

@Test
public void testLegacySuccessAndFailure() {
for (int code : new int[]{200, 400}) {
String body = "{\"requestId\":\"request\",\"status\":{\"code\":" + code +
",\"message\":\"original\",\"attributes\":{}}," +
"\"result\":{\"data\":" + (code == 200 ? "[]" : "null") +
",\"meta\":{}}}";
Response response = read(body);
Assert.assertEquals(code, response.status().code());
Assert.assertEquals("original", response.status().message());
Assert.assertTrue(response.status().attributes().isEmpty());
}
}

@Test
public void testCurrentSuccess() throws Exception {
Response response = read(JSON.writeValueAsString(CypherModel.dataOf(
"request", Collections.singletonList(Collections.singletonMap("value", 1)))));
Assert.assertEquals(200, response.status().code());
Assert.assertEquals("request", response.requestId());
Assert.assertTrue(response.status().attributes().isEmpty());
Assert.assertEquals(1, response.result().size());
}

@Test
public void testCurrentFailure() throws Exception {
CypherModel.CypherError error = CypherErrorMapper.map(
new IllegalArgumentException("Invalid input 'R'"));
Response response = read(JSON.writeValueAsString(
CypherModel.failOf("request", "original failure", error)));
// CypherManager checks this status before handing the result to Hubble.
Assert.assertEquals(400, response.status().code());
Assert.assertEquals("original failure", response.status().message());
Assert.assertEquals("HugeGraph.Cypher.SyntaxError", JSON.valueToTree(
response.status().attributes()).at("/errors/0/code").asText());
}

@Test
public void testTopLevelErrorsNegativeControl() throws Exception {
ObjectNode body = (ObjectNode) JSON.readTree(JSON.writeValueAsString(
CypherModel.dataOf("request", Collections.emptyList())));
body.putArray("errors");
RuntimeException failure = Assert.assertThrows(RuntimeException.class,
() -> read(body.toString()));
Throwable cause = failure;
while (cause.getCause() != null) {
cause = cause.getCause();
}
Assert.assertEquals("UnrecognizedPropertyException", cause.getClass().getSimpleName());
Assert.assertTrue(cause.getMessage().contains("errors"));
}

private static Response read(String body) {
return new RestResult(200, body, null).readObject(Response.class);
}
}
Loading
Loading