diff --git a/client/generator/pom.xml b/client/generator/pom.xml index 5f06a3770..67f90b1fd 100644 --- a/client/generator/pom.xml +++ b/client/generator/pom.xml @@ -67,12 +67,6 @@ ${project.version} provided - - io.smallrye - smallrye-graphql-api - ${project.version} - provided - com.graphql-java graphql-java diff --git a/server/api/src/main/java/io/smallrye/graphql/api/ErrorCode.java b/server/api/src/main/java/io/smallrye/graphql/api/ErrorCode.java new file mode 100644 index 000000000..faa28543d --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/ErrorCode.java @@ -0,0 +1,14 @@ +package io.smallrye.graphql.api; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Retention(RUNTIME) +@Target({ TYPE, ANNOTATION_TYPE }) +public @interface ErrorCode { + String value(); +} diff --git a/server/implementation/src/main/java/io/smallrye/graphql/execution/error/ExecutionErrorsService.java b/server/implementation/src/main/java/io/smallrye/graphql/execution/error/ExecutionErrorsService.java index 175c62c23..5193a3b9b 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/execution/error/ExecutionErrorsService.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/execution/error/ExecutionErrorsService.java @@ -1,5 +1,7 @@ package io.smallrye.graphql.execution.error; +import static java.util.Locale.UK; + import java.io.StringReader; import java.util.List; import java.util.Map; @@ -20,6 +22,7 @@ import graphql.ExceptionWhileDataFetching; import graphql.GraphQLError; import graphql.validation.ValidationError; +import io.smallrye.graphql.api.ErrorCode; /** * Help to create the exceptions @@ -50,10 +53,7 @@ private JsonObject toJsonError(GraphQLError error) { JsonObjectBuilder resultBuilder = jsonBuilderFactory.createObjectBuilder(jsonErrors); - Optional optionalExtensions = getOptionalExtensions(error); - if (optionalExtensions.isPresent()) { - resultBuilder.add(EXTENSIONS, optionalExtensions.get()); - } + getOptionalExtensions(error).ifPresent(jsonObject -> resultBuilder.add(EXTENSIONS, jsonObject)); return resultBuilder.build(); } } @@ -86,12 +86,26 @@ private Optional getDataFetchingExtensions(ExceptionWhileDataFetchin addKeyValue(objectBuilder, EXCEPTION, exception.getClass().getName()); addKeyValue(objectBuilder, CLASSIFICATION, error.getErrorType().toString()); + addKeyValue(objectBuilder, CODE, toErrorCode(exception)); Map extensions = error.getExtensions(); populateCustomExtensions(objectBuilder, extensions); return Optional.of(objectBuilder.build()); } + private String toErrorCode(Throwable exception) { + // TODO change to use the Model and the model needs to get this using Jandex + ErrorCode annotation = exception.getClass().getAnnotation(ErrorCode.class); + return (annotation == null) + ? camelToKebab(exception.getClass().getSimpleName().replaceAll("Exception$", "")) + : annotation.value(); + } + + private static String camelToKebab(String input) { + return String.join("-", input.split("(?=\\p{javaUpperCase})")) + .toLowerCase(UK); + } + private void populateCustomExtensions(JsonObjectBuilder objectBuilder, Map extensions) { if (extensions != null) { for (Map.Entry entry : extensions.entrySet()) { @@ -121,5 +135,6 @@ private void addKeyValue(JsonObjectBuilder objectBuilder, String key, String val private static final String QUERYPATH = "queryPath"; private static final String CLASSIFICATION = "classification"; private static final String EXTENSIONS = "extensions"; + private static final String CODE = "code"; } diff --git a/server/implementation/src/test/java/io/smallrye/graphql/execution/error/ExecutionErrorsServiceTest.java b/server/implementation/src/test/java/io/smallrye/graphql/execution/error/ExecutionErrorsServiceTest.java index 378da0146..83f3417e5 100644 --- a/server/implementation/src/test/java/io/smallrye/graphql/execution/error/ExecutionErrorsServiceTest.java +++ b/server/implementation/src/test/java/io/smallrye/graphql/execution/error/ExecutionErrorsServiceTest.java @@ -1,23 +1,24 @@ package io.smallrye.graphql.execution.error; +import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import javax.json.JsonArray; import javax.json.JsonObject; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import graphql.ExceptionWhileDataFetching; +import graphql.GraphQLError; import graphql.GraphqlErrorException; import graphql.execution.ExecutionPath; import graphql.language.SourceLocation; import graphql.validation.ValidationError; import graphql.validation.ValidationErrorType; +import io.smallrye.graphql.api.ErrorCode; /** * Test for {@link ExecutionErrorsService} @@ -26,18 +27,13 @@ */ class ExecutionErrorsServiceTest { - private ExecutionErrorsService executionErrorsService; - - @BeforeEach - void init() { - executionErrorsService = new ExecutionErrorsService(); - } + private final ExecutionErrorsService executionErrorsService = new ExecutionErrorsService(); @Test void testToJsonErrors_WhenExceptionWhileDataFetchingErrorCaught_ShouldReturnJsonBodyWithCustomExtensions() { // Given Map extensions = new HashMap<>(); - extensions.put("errorCode", "OPERATION_FAILED"); + extensions.put("code", "OPERATION_FAILED"); GraphqlErrorException graphqlErrorException = GraphqlErrorException.newErrorException() .extensions(extensions) .build(); @@ -45,29 +41,29 @@ void testToJsonErrors_WhenExceptionWhileDataFetchingErrorCaught_ShouldReturnJson graphqlErrorException, new SourceLocation(1, 1)); // When - JsonArray jsonArray = executionErrorsService.toJsonErrors(Collections.singletonList(exceptionWhileDataFetching)); + JsonArray jsonArray = executionErrorsService.toJsonErrors(singletonList(exceptionWhileDataFetching)); // Then JsonObject extensionJsonObject = jsonArray.getJsonObject(0).getJsonObject("extensions"); assertThat(extensionJsonObject.getString("exception")).isEqualTo("graphql.GraphqlErrorException"); assertThat(extensionJsonObject.getString("classification")).isEqualTo("DataFetchingException"); - assertThat(extensionJsonObject.getString("errorCode")).isEqualTo("OPERATION_FAILED"); + assertThat(extensionJsonObject.getString("code")).isEqualTo("OPERATION_FAILED"); } @Test void testToJsonErrors_WhenExceptionWhileValidationErrorCaught_ShouldReturnJsonBodyWithCustomExtensions() { // Given Map extensions = new HashMap<>(); - extensions.put("errorCode", "OPERATION_FAILED"); + extensions.put("code", "OPERATION_FAILED"); ValidationError validationError = ValidationError.newValidationError() .validationErrorType(ValidationErrorType.UnknownDirective) .description("TestDescription") - .queryPath(Collections.singletonList("Test-Path")) + .queryPath(singletonList("Test-Path")) .extensions(extensions) .build(); // When - JsonArray jsonArray = executionErrorsService.toJsonErrors(Collections.singletonList(validationError)); + JsonArray jsonArray = executionErrorsService.toJsonErrors(singletonList(validationError)); // Then JsonObject extensionJsonObject = jsonArray.getJsonObject(0).getJsonObject("extensions"); @@ -75,6 +71,44 @@ void testToJsonErrors_WhenExceptionWhileValidationErrorCaught_ShouldReturnJsonBo assertThat(extensionJsonObject.getString("validationErrorType")).isEqualTo("UnknownDirective"); assertThat(extensionJsonObject.getJsonArray("queryPath").getString(0)).isEqualTo("Test-Path"); assertThat(extensionJsonObject.getString("classification")).isEqualTo("ValidationError"); - assertThat(extensionJsonObject.getString("errorCode")).isEqualTo("OPERATION_FAILED"); + assertThat(extensionJsonObject.getString("code")).isEqualTo("OPERATION_FAILED"); + } + + @Test + void shouldMapExceptionNameToCode() { + class DummyBusinessException extends RuntimeException { + public DummyBusinessException(String message) { + super(message); + } + } + + JsonArray jsonArray = whenConverting(new DummyBusinessException("dummy-message")); + + JsonObject extensions = jsonArray.getJsonObject(0).getJsonObject("extensions"); + assertThat(extensions.getString("exception")).isEqualTo(DummyBusinessException.class.getName()); + assertThat(extensions.getString("code", null)).isEqualTo("dummy-business"); + } + + @Test + void shouldMapClassAnnotationErrorCode() { + @ErrorCode("dummy-code") + class DummyBusinessException extends RuntimeException { + public DummyBusinessException(String message) { + super(message); + } + } + + JsonArray jsonArray = whenConverting(new DummyBusinessException("dummy-message")); + + JsonObject extensions = jsonArray.getJsonObject(0).getJsonObject("extensions"); + assertThat(extensions.getString("exception")).isEqualTo(DummyBusinessException.class.getName()); + assertThat(extensions.getString("code", null)).isEqualTo("dummy-code"); + } + + private JsonArray whenConverting(RuntimeException exception) { + ExecutionPath path = ExecutionPath.parse("/foo/bar"); + SourceLocation location = new SourceLocation(12, 34); + GraphQLError graphQLError = new GraphQLExceptionWhileDataFetching(path, exception, location); + return executionErrorsService.toJsonErrors(singletonList(graphQLError)); } } diff --git a/server/tck/src/test/java/io/smallrye/graphql/SmallRyeGraphQLArchiveProcessor.java b/server/tck/src/test/java/io/smallrye/graphql/SmallRyeGraphQLArchiveProcessor.java index b28531d20..447610658 100644 --- a/server/tck/src/test/java/io/smallrye/graphql/SmallRyeGraphQLArchiveProcessor.java +++ b/server/tck/src/test/java/io/smallrye/graphql/SmallRyeGraphQLArchiveProcessor.java @@ -28,6 +28,7 @@ import org.jboss.shrinkwrap.resolver.api.maven.Maven; import io.smallrye.graphql.test.apps.async.api.AsyncApi; +import io.smallrye.graphql.test.apps.error.api.ErrorApi; import io.smallrye.graphql.test.apps.profile.api.ProfileGraphQLApi; import io.smallrye.graphql.test.apps.scalars.api.AdditionalScalarsApi; @@ -70,6 +71,7 @@ public void process(Archive applicationArchive, TestClass testClass) { testDeployment.addPackage(ProfileGraphQLApi.class.getPackage()); testDeployment.addPackage(AdditionalScalarsApi.class.getPackage()); testDeployment.addPackage(AsyncApi.class.getPackage()); + testDeployment.addPackage(ErrorApi.class.getPackage()); } } } diff --git a/server/tck/src/test/java/io/smallrye/graphql/test/apps/error/api/ErrorApi.java b/server/tck/src/test/java/io/smallrye/graphql/test/apps/error/api/ErrorApi.java new file mode 100644 index 000000000..c82d4b3c7 --- /dev/null +++ b/server/tck/src/test/java/io/smallrye/graphql/test/apps/error/api/ErrorApi.java @@ -0,0 +1,31 @@ +package io.smallrye.graphql.test.apps.error.api; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; + +import io.smallrye.graphql.api.ErrorCode; + +@GraphQLApi +public class ErrorApi { + @Query + public String unsupportedOperation() { + throw new UnsupportedOperationException("dummy-message"); + } + + public static class CustomBusinessException extends RuntimeException { + } + + @Query + public String customBusinessException() { + throw new CustomBusinessException(); + } + + @ErrorCode("some-business-error-code") + public static class AnnotatedCustomBusinessException extends RuntimeException { + } + + @Query + public String annotatedCustomBusinessException() { + throw new AnnotatedCustomBusinessException(); + } +} diff --git a/server/tck/src/test/resources/tests/errors/annotatedCustomException/input.graphql b/server/tck/src/test/resources/tests/errors/annotatedCustomException/input.graphql new file mode 100644 index 000000000..214614c53 --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/annotatedCustomException/input.graphql @@ -0,0 +1,3 @@ +{ + annotatedCustomBusinessException +} diff --git a/server/tck/src/test/resources/tests/errors/annotatedCustomException/output.json b/server/tck/src/test/resources/tests/errors/annotatedCustomException/output.json new file mode 100644 index 000000000..10f3f400a --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/annotatedCustomException/output.json @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "Unexpected failure in the system. Jarvis is working to fix it.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "annotatedCustomBusinessException" + ], + "extensions": { + "exception": "io.smallrye.graphql.test.apps.error.api.ErrorApi$AnnotatedCustomBusinessException", + "classification": "DataFetchingException", + "code": "some-business-error-code" + } + } + ], + "data": { + "annotatedCustomBusinessException": null + } +} diff --git a/server/tck/src/test/resources/tests/errors/annotatedCustomException/test.properties b/server/tck/src/test/resources/tests/errors/annotatedCustomException/test.properties new file mode 100644 index 000000000..faa5dd321 --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/annotatedCustomException/test.properties @@ -0,0 +1,4 @@ +## error extension `code` derived from the `@GraphQlErrorCode` annotation on a custom exception + +ignore=false +priority=100 diff --git a/server/tck/src/test/resources/tests/errors/builtInException/input.graphql b/server/tck/src/test/resources/tests/errors/builtInException/input.graphql new file mode 100644 index 000000000..c90e34926 --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/builtInException/input.graphql @@ -0,0 +1,3 @@ +{ + unsupportedOperation +} diff --git a/server/tck/src/test/resources/tests/errors/builtInException/output.json b/server/tck/src/test/resources/tests/errors/builtInException/output.json new file mode 100644 index 000000000..b62440f86 --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/builtInException/output.json @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "Unexpected failure in the system. Jarvis is working to fix it.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "unsupportedOperation" + ], + "extensions": { + "exception": "java.lang.UnsupportedOperationException", + "classification": "DataFetchingException", + "code": "unsupported-operation" + } + } + ], + "data": { + "unsupportedOperation": null + } +} diff --git a/server/tck/src/test/resources/tests/errors/builtInException/test.properties b/server/tck/src/test/resources/tests/errors/builtInException/test.properties new file mode 100644 index 000000000..dd460341c --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/builtInException/test.properties @@ -0,0 +1,4 @@ +## error extension `code` derived from the class name of a jdk exception + +ignore=false +priority=100 diff --git a/server/tck/src/test/resources/tests/errors/customException/input.graphql b/server/tck/src/test/resources/tests/errors/customException/input.graphql new file mode 100644 index 000000000..46cea77b4 --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/customException/input.graphql @@ -0,0 +1,3 @@ +{ + customBusinessException +} diff --git a/server/tck/src/test/resources/tests/errors/customException/output.json b/server/tck/src/test/resources/tests/errors/customException/output.json new file mode 100644 index 000000000..11cc17c3e --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/customException/output.json @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "Unexpected failure in the system. Jarvis is working to fix it.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "customBusinessException" + ], + "extensions": { + "exception": "io.smallrye.graphql.test.apps.error.api.ErrorApi$CustomBusinessException", + "classification": "DataFetchingException", + "code": "custom-business" + } + } + ], + "data": { + "customBusinessException": null + } +} diff --git a/server/tck/src/test/resources/tests/errors/customException/test.properties b/server/tck/src/test/resources/tests/errors/customException/test.properties new file mode 100644 index 000000000..eb78b031e --- /dev/null +++ b/server/tck/src/test/resources/tests/errors/customException/test.properties @@ -0,0 +1,4 @@ +## error extension `code` derived from the class name of a custom exception + +ignore=false +priority=100