Bael-5306: graphql error handling (#12041)

* Example implementation of Hexagonal Architecture pattern

* BAEL-5306 - spring boot/graphql error handling example

* removed the ddd hexagonal arch module

Co-authored-by: Suresh Raghavan <contactnrsuresh@gmail.com>
This commit is contained in:
nrsureshdeveloper 2022-04-15 01:49:56 -05:00 committed by GitHub
parent 9f584fd32f
commit f12099f7dd
26 changed files with 680 additions and 0 deletions

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.baeldung.graphql</groupId>
<artifactId>graphql-error-handling</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<name>graphql-error-handling</name>
<parent>
<groupId>com.baeldung</groupId>
<artifactId>parent-boot-2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../parent-boot-2</relativePath>
</parent>
<properties>
<java.version>1.8</java.version>
<lombok.version>1.18.18</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-spring-boot-starter</artifactId>
<version>5.0.2</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-tools</artifactId>
<version>5.2.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
<version>2.6.4</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>5.0.2</version>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.22.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.skyscreamer</groupId>
<artifactId>jsonassert</artifactId>
<version>1.5.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,46 @@
package com.baeldung.graphql.error.handling;
import com.baeldung.graphql.error.handling.exception.GraphQLErrorAdapter;
import graphql.ExceptionWhileDataFetching;
import graphql.GraphQLError;
import graphql.servlet.GraphQLErrorHandler;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@SpringBootApplication
public class GraphQLErrorHandlerApplication {
public static void main(String[] args) {
SpringApplication.run(GraphQLErrorHandlerApplication.class, args);
}
@Bean
public GraphQLErrorHandler errorHandler() {
return new GraphQLErrorHandler() {
@Override
public List<GraphQLError> processErrors(List<GraphQLError> errors) {
List<GraphQLError> clientErrors = errors.stream()
.filter(this::isClientError)
.collect(Collectors.toList());
List<GraphQLError> serverErrors = errors.stream()
.filter(e -> !isClientError(e))
.map(GraphQLErrorAdapter::new)
.collect(Collectors.toList());
List<GraphQLError> e = new ArrayList<>();
e.addAll(clientErrors);
e.addAll(serverErrors);
return e;
}
private boolean isClientError(GraphQLError error) {
return !(error instanceof ExceptionWhileDataFetching || error instanceof Throwable);
}
};
}
}

View File

@ -0,0 +1,29 @@
package com.baeldung.graphql.error.handling.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Data
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Location {
@Id
private String zipcode;
private String city;
private String state;
@OneToMany(mappedBy = "location", fetch = FetchType.EAGER)
private List<Vehicle> vehicles = new ArrayList<>();
}

View File

@ -0,0 +1,26 @@
package com.baeldung.graphql.error.handling.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Data
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Vehicle {
@Id
private String vin;
private Integer year;
private String make;
private String model;
private String trim;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "fk_location")
private Location location;
}

View File

@ -0,0 +1,44 @@
package com.baeldung.graphql.error.handling.exception;
import graphql.ErrorType;
import graphql.GraphQLError;
import graphql.language.SourceLocation;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class AbstractGraphQLException extends RuntimeException implements GraphQLError {
private Map<String, Object> parameters = new HashMap();
public AbstractGraphQLException(String message) {
super(message);
}
public AbstractGraphQLException(String message, Map<String, Object> additionParams) {
this(message);
if (additionParams != null) {
parameters = additionParams;
}
}
@Override
public String getMessage() {
return super.getMessage();
}
@Override
public List<SourceLocation> getLocations() {
return null;
}
@Override
public ErrorType getErrorType() {
return null;
}
@Override
public Map<String, Object> getExtensions() {
return this.parameters;
}
}

View File

@ -0,0 +1,48 @@
package com.baeldung.graphql.error.handling.exception;
import graphql.ErrorType;
import graphql.ExceptionWhileDataFetching;
import graphql.GraphQLError;
import graphql.language.SourceLocation;
import java.util.List;
import java.util.Map;
public class GraphQLErrorAdapter implements GraphQLError {
private GraphQLError error;
public GraphQLErrorAdapter(GraphQLError error) {
this.error = error;
}
@Override
public Map<String, Object> getExtensions() {
return error.getExtensions();
}
@Override
public List<SourceLocation> getLocations() {
return error.getLocations();
}
@Override
public ErrorType getErrorType() {
return error.getErrorType();
}
@Override
public List<Object> getPath() {
return error.getPath();
}
@Override
public Map<String, Object> toSpecification() {
return error.toSpecification();
}
@Override
public String getMessage() {
return (error instanceof ExceptionWhileDataFetching) ? ((ExceptionWhileDataFetching) error).getException().getMessage() : error.getMessage();
}
}

View File

@ -0,0 +1,7 @@
package com.baeldung.graphql.error.handling.exception;
public class InvalidInputException extends RuntimeException {
public InvalidInputException(String message) {
super(message);
}
}

View File

@ -0,0 +1,14 @@
package com.baeldung.graphql.error.handling.exception;
import java.util.Map;
public class VehicleAlreadyPresentException extends AbstractGraphQLException {
public VehicleAlreadyPresentException(String message) {
super(message);
}
public VehicleAlreadyPresentException(String message, Map<String, Object> additionParams) {
super(message, additionParams);
}
}

View File

@ -0,0 +1,14 @@
package com.baeldung.graphql.error.handling.exception;
import java.util.Map;
public class VehicleNotFoundException extends AbstractGraphQLException {
public VehicleNotFoundException(String message) {
super(message);
}
public VehicleNotFoundException(String message, Map<String, Object> params) {
super(message, params);
}
}

View File

@ -0,0 +1,9 @@
package com.baeldung.graphql.error.handling.repository;
import com.baeldung.graphql.error.handling.domain.Vehicle;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface InventoryRepository extends JpaRepository<Vehicle, String> {
}

View File

@ -0,0 +1,9 @@
package com.baeldung.graphql.error.handling.repository;
import com.baeldung.graphql.error.handling.domain.Location;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LocationRepository extends JpaRepository<Location, String> {
}

View File

@ -0,0 +1,20 @@
package com.baeldung.graphql.error.handling.resolver;
import com.baeldung.graphql.error.handling.domain.Location;
import com.baeldung.graphql.error.handling.domain.Vehicle;
import com.baeldung.graphql.error.handling.service.InventoryService;
import com.coxautodev.graphql.tools.GraphQLMutationResolver;
import org.springframework.stereotype.Component;
@Component
public class Mutation implements GraphQLMutationResolver {
private InventoryService inventoryService;
public Mutation(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
public Vehicle addVehicle(String vin, Integer year, String make, String model, String trim, Location location) {
return this.inventoryService.addVehicle(vin, year, make, model, trim, location);
}
}

View File

@ -0,0 +1,29 @@
package com.baeldung.graphql.error.handling.resolver;
import com.baeldung.graphql.error.handling.domain.Vehicle;
import com.baeldung.graphql.error.handling.service.InventoryService;
import com.coxautodev.graphql.tools.GraphQLQueryResolver;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class Query implements GraphQLQueryResolver {
private final InventoryService inventoryService;
public Query(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
public List<Vehicle> searchAll() {
return this.inventoryService.searchAll();
}
public List<Vehicle> searchByLocation(String zipcode) {
return this.inventoryService.searchByLocation(zipcode);
}
public Vehicle searchByVin(String vin) {
return this.inventoryService.searchByVin(vin);
}
}

View File

@ -0,0 +1,67 @@
package com.baeldung.graphql.error.handling.service;
import com.baeldung.graphql.error.handling.domain.Location;
import com.baeldung.graphql.error.handling.domain.Vehicle;
import com.baeldung.graphql.error.handling.exception.InvalidInputException;
import com.baeldung.graphql.error.handling.exception.VehicleAlreadyPresentException;
import com.baeldung.graphql.error.handling.exception.VehicleNotFoundException;
import com.baeldung.graphql.error.handling.repository.InventoryRepository;
import com.baeldung.graphql.error.handling.repository.LocationRepository;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.*;
@Service
public class InventoryService {
private InventoryRepository inventoryRepository;
private LocationRepository locationRepository;
public InventoryService(InventoryRepository inventoryRepository, LocationRepository locationRepository) {
this.inventoryRepository = inventoryRepository;
this.locationRepository = locationRepository;
}
@Transactional
public Vehicle addVehicle(String vin, Integer year, String make, String model, String trim, Location location) {
Optional<Vehicle> existingVehicle = this.inventoryRepository.findById(vin);
if (existingVehicle.isPresent()) {
Map<String, Object> params = new HashMap<>();
params.put("vin", vin);
throw new VehicleAlreadyPresentException("Failed to add vehicle. Vehicle with vin " + vin + " already present.", params);
}
Vehicle vehicle = Vehicle.builder()
.vin(vin)
.year(year)
.make(make)
.model(model)
.location(location)
.trim(trim)
.build();
this.locationRepository.save(location);
return this.inventoryRepository.save(vehicle);
}
public List<Vehicle> searchAll() {
return this.inventoryRepository.findAll();
}
public List<Vehicle> searchByLocation(String zipcode) {
if (StringUtils.isEmpty(zipcode) || zipcode.length() != 5) {
throw new InvalidInputException("Invalid zipcode " + zipcode + " provided.");
}
return this.locationRepository.findById(zipcode)
.map(Location::getVehicles)
.orElse(new ArrayList<>());
}
public Vehicle searchByVin(String vin) {
return this.inventoryRepository.findById(vin).orElseThrow(() -> {
Map<String, Object> params = new HashMap<>();
params.put("vin", vin);
return new VehicleNotFoundException("Vehicle with vin " + vin + " not found.", params);
});
}
}

View File

@ -0,0 +1,23 @@
graphql:
servlet:
mapping: /graphql
spring:
datasource:
url: "jdbc:h2:mem:graphqldb"
driverClassName: "org.h2.Driver"
username: sa
password:
initialization-mode: always
platform: h2
jpa:
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
ddl-auto: none
h2:
console.enabled: true

View File

@ -0,0 +1,23 @@
type Vehicle {
vin: ID!
year: Int!
make: String!
model: String!
trim: String!
}
input Location {
city: String
state: String
zipcode: String!
}
type Query {
searchAll: [Vehicle]!
searchByLocation(zipcode: String!): [Vehicle]!
searchByVin(vin: String!): Vehicle
}
type Mutation {
addVehicle(vin: ID!, year: Int!, make: String!, model: String!, trim: String, location: Location): Vehicle!
}

View File

@ -0,0 +1,7 @@
insert into LOCATION values('07092', 'Mountainside', 'NJ');
insert into LOCATION values ('94118', 'San Francisco', 'CA');
insert into LOCATION values ('10002', 'New York', 'NY');
insert into VEHICLE (vin, year, make, model, trim, fk_location) values('KM8JN72DX7U587496', 2007, 'Hyundai', 'Tucson', null, '07092');
insert into VEHICLE (vin, year, make, model, trim, fk_location) values('JTKKU4B41C1023346', 2012, 'Toyota', 'Scion', 'Xd', '94118');
insert into VEHICLE (vin, year, make, model, trim, fk_location) values('1G1JC1444PZ215071', 2000, 'Chevrolet', 'CAVALIER VL', 'RS', '07092');

View File

@ -0,0 +1,55 @@
package com.baeldung.graphql.error.handling;
import com.graphql.spring.boot.test.GraphQLResponse;
import com.graphql.spring.boot.test.GraphQLTestTemplate;
import org.json.JSONException;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.IOException;
import static com.baeldung.graphql.error.handling.TestUtils.readFile;
import static java.lang.String.format;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = GraphQLErrorHandlerApplication.class)
public class GraphQLErrorHandlerApplicationIntegrationTest {
@Autowired
private GraphQLTestTemplate graphQLTestTemplate;
private static final String GRAPHQL_TEST_REQUEST_PATH = "graphql/request/%s.graphql";
private static final String GRAPHQL_TEST_RESPONSE_PATH = "graphql/response/%s.json";
@Test
public void whenUnknownOperation_thenRespondWithRequestError() throws IOException, JSONException {
String graphqlName = "request_error_unknown_operation";
GraphQLResponse actualResponse = graphQLTestTemplate.postForResource(format(GRAPHQL_TEST_REQUEST_PATH, graphqlName));
String expectedResponse = readFile(format(GRAPHQL_TEST_RESPONSE_PATH, graphqlName));
JSONAssert.assertEquals(expectedResponse, actualResponse.getRawResponse().getBody(), true);
}
@Test
public void whenInvalidSyntaxRequest_thenRespondWithRequestError() throws IOException, JSONException {
String graphqlName = "request_error_invalid_request_syntax";
GraphQLResponse actualResponse = graphQLTestTemplate.postForResource(format(GRAPHQL_TEST_REQUEST_PATH, graphqlName));
String expectedResponse = readFile(format(GRAPHQL_TEST_RESPONSE_PATH, graphqlName));
JSONAssert.assertEquals(expectedResponse, actualResponse.getRawResponse().getBody(), true);
}
@Test
public void whenRequestAllNonNullField_thenRespondPartialDataWithFieldError() throws IOException, JSONException {
String graphqlName = "field_error_request_non_null_fields_partial_response";
GraphQLResponse actualResponse = graphQLTestTemplate.postForResource(format(GRAPHQL_TEST_REQUEST_PATH, graphqlName));
String expectedResponse = readFile(format(GRAPHQL_TEST_RESPONSE_PATH, graphqlName));
JSONAssert.assertEquals(expectedResponse, actualResponse.getRawResponse().getBody(), true);
}
}

View File

@ -0,0 +1,15 @@
package com.baeldung.graphql.error.handling;
import org.apache.commons.io.IOUtils;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.Charset;
public class TestUtils {
public static String readFile(String path) throws IOException {
return IOUtils.toString(
new ClassPathResource(path).getInputStream(), Charset.defaultCharset()
);
}
}

View File

@ -0,0 +1,10 @@
# trim is non null but one of the record has null value for trim
query {
searchAll {
vin
year
make
model
trim
}
}

View File

@ -0,0 +1,9 @@
query {
searchByVin(vin: "error) {
vin
year
make
model
trim
}
}

View File

@ -0,0 +1,9 @@
subscription {
searchByVin(vin: "75024") {
vin
year
make
model
trim
}
}

View File

@ -0,0 +1,34 @@
{
"data": {
"searchAll": [
null,
{
"vin": "JTKKU4B41C1023346",
"year": 2012,
"make": "Toyota",
"model": "Scion",
"trim": "Xd"
},
{
"vin": "1G1JC1444PZ215071",
"year": 2000,
"make": "Chevrolet",
"model": "CAVALIER VL",
"trim": "RS"
}
]
},
"errors": [
{
"message": "Cannot return null for non-nullable type: 'String' within parent 'Vehicle' (/searchAll[0]/trim)",
"path": [
"searchAll",
0,
"trim"
],
"errorType": "DataFetchingException",
"locations": null,
"extensions": null
}
]
}

View File

@ -0,0 +1,18 @@
{
"data": null,
"errors": [
{
"message": "Invalid Syntax",
"locations": [
{
"line": 5,
"column": 8,
"sourceName": null
}
],
"errorType": "InvalidSyntax",
"path": null,
"extensions": null
}
]
}

View File

@ -0,0 +1,18 @@
{
"data": null,
"errors": [
{
"errorType": "OperationNotSupported",
"locations": [
{
"line": 1,
"column": 1,
"sourceName": null
}
],
"extensions": null,
"message": "Schema is not configured for subscriptions.",
"path": null
}
]
}