Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

graphql #50

Merged
merged 6 commits into from
Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,20 @@
<testcontainers.version>1.18.0</testcontainers.version>
<elasticsearch.version>5.1.0</elasticsearch.version>
<prometheus.version>1.11.0</prometheus.version>
<graphql.validation.version>20.0</graphql.validation.version>
</properties>

<dependencies>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-validation</artifactId>
<version>${graphql.validation.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-elasticsearch</artifactId>
Expand Down
76 changes: 76 additions & 0 deletions src/main/java/com/solvd/micro9/users/web/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@

import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import graphql.language.StringValue;
import graphql.schema.Coercing;
import graphql.schema.CoercingParseValueException;
import graphql.schema.CoercingSerializeException;
import graphql.schema.GraphQLScalarType;
import graphql.schema.CoercingParseLiteralException;
import graphql.validation.rules.OnValidationErrorStrategy;
import graphql.validation.rules.ValidationRules;
import graphql.validation.schemawiring.ValidationSchemaWiring;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.web.ReactivePageableHandlerMethodArgumentResolver;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;

@Configuration
public class WebConfig implements WebFluxConfigurer {
Expand Down Expand Up @@ -43,4 +54,69 @@ public WebClient.Builder webClient() {
return WebClient.builder();
}

public GraphQLScalarType localDateTimeScalar() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva try to make more elegant solution here

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done 679c337

return GraphQLScalarType.newScalar()
.name("LocalDateTime")
.description("Java 8 LocalDateTime as scalar")
.coercing(new Coercing<LocalDateTime, String>() {
@Override
public String serialize(final Object input) {
if (input instanceof LocalDateTime) {
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return ((LocalDateTime) input).format(formatter);
} else {
throw new CoercingSerializeException(
"Expected a LocalDateTime object"
);
}
}

@Override
public LocalDateTime parseValue(final Object input) {
try {
if (input instanceof String) {
return LocalDateTime.parse((String) input);
} else {
throw new CoercingParseValueException("Expected a String");
}
} catch (DateTimeParseException e) {
throw new CoercingParseValueException(
String.format("Not a valid date: '%s'.", input), e
);
}
}

@Override
public LocalDateTime parseLiteral(final Object input) {
if (input instanceof StringValue) {
try {
return LocalDateTime.parse(((StringValue) input)
.getValue());
} catch (DateTimeParseException e) {
throw new CoercingParseLiteralException(e);
}
} else {
throw new CoercingParseLiteralException(
"Expected a StringValue"
);
}
}
}).build();
}

public ValidationSchemaWiring schemaWiring() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva as far I understand this ValidationSchemaWiring also should be represented as Spring Bean

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h1alexbel I call this method in another method, then why it should be a bean?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva for code reuse?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h1alexbel but I call this method only once

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva I know, but what about future usages?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h1alexbel according to YAGNI principle features should only be added when required

Copy link
Collaborator

@h1alexbel h1alexbel Jun 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva YAGNI is about features or FR, not about maintainability, which is NFR

try to design all your classes, methods, functions with maintainability in mind

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done 679c337

ValidationRules validationRules = ValidationRules.newValidationRules()
.onValidationErrorStrategy(OnValidationErrorStrategy.RETURN_NULL)
.build();
return new ValidationSchemaWiring(validationRules);
}

@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(localDateTimeScalar())
.directiveWiring(schemaWiring());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.solvd.micro9.users.web.controller;

import com.solvd.micro9.users.domain.aggregate.User;
import com.solvd.micro9.users.domain.command.CreateUserCommand;
import com.solvd.micro9.users.domain.command.DeleteUserCommand;
import com.solvd.micro9.users.domain.criteria.UserCriteria;
import com.solvd.micro9.users.domain.es.EsUser;
import com.solvd.micro9.users.domain.query.EsUserQuery;
import com.solvd.micro9.users.service.EsUserCommandHandler;
import com.solvd.micro9.users.service.UserQueryHandler;
import com.solvd.micro9.users.web.mapper.UserCriteriaMapper;
import com.solvd.micro9.users.web.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Controller
@RequiredArgsConstructor
public class GraphqlUserController {

private final EsUserCommandHandler commandHandler;
private final UserQueryHandler queryHandler;
private final UserMapper userMapper;
private final UserCriteriaMapper criteriaMapper;

@QueryMapping("getAllUsers")
public Flux<User> getAll() {
return queryHandler.getAll();
}

@QueryMapping("findByCriteria")
public Flux<User> findByCriteria(@Argument final UserCriteria criteria,
@Argument final int size,
@Argument final int page) {
Pageable pageable = PageRequest.of(page, size);
return queryHandler.findByCriteria(criteria, pageable);
}

@QueryMapping("findUserById")
public Mono<User> findByUserId(@Argument final String userId) {
EsUserQuery query = new EsUserQuery(userId);
return queryHandler.findById(query);
}

@MutationMapping("createUser")
public Mono<EsUser> create(@Argument final User user) {
CreateUserCommand command = new CreateUserCommand(user, "Liza123");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva what is the "Liza123"?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed 679c337

return commandHandler.apply(command);
}

@MutationMapping("deleteUser")
public Mono<EsUser> delete(@Argument final String id) {
DeleteUserCommand command = new DeleteUserCommand(id, "Liza123");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva the same goes here

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed 679c337

return commandHandler.apply(command);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.solvd.micro9.users.web.dto.TicketDto;
import com.solvd.micro9.users.web.dto.UserDto;
import com.solvd.micro9.users.web.dto.criteria.UserCriteriaDto;
import com.solvd.micro9.users.web.mapper.EsMapper;
import com.solvd.micro9.users.web.mapper.UserCriteriaMapper;
import com.solvd.micro9.users.web.mapper.UserMapper;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
Expand All @@ -31,24 +32,25 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
public class RestUserController {

@Value("${ticket-service}")
private String ticketService;
private final EsUserCommandHandler commandHandler;
private final UserQueryHandler queryHandler;
private final UserMapper userMapper;
private final EsMapper esMapper;
private final WebClient.Builder webClientBuilder;
private final UserCriteriaMapper criteriaMapper;
private static final String USER_SERVICE = "user-service";
Expand Down Expand Up @@ -117,16 +119,18 @@ public Mono<EsDto> createTicket(
}

@PostMapping
public Mono<EsUser> create(@RequestBody @Validated final UserDto userDto) {
public Mono<EsDto> create(@RequestBody @Validated final UserDto userDto) {
User user = userMapper.dtoToDomain(userDto);
CreateUserCommand command = new CreateUserCommand(user, "Liza123");
return commandHandler.apply(command);
Mono<EsUser> esUserMono = commandHandler.apply(command);
return esMapper.domainToDto(esUserMono);
}

@DeleteMapping(value = "/{id}")
public Mono<EsUser> delete(@PathVariable("id") final String id) {
public Mono<EsDto> delete(@PathVariable("id") final String id) {
DeleteUserCommand command = new DeleteUserCommand(id, "Liza123");
return commandHandler.apply(command);
Mono<EsUser> esUserMono = commandHandler.apply(command);
return esMapper.domainToDto(esUserMono);
}

@SneakyThrows
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.solvd.micro9.users.web.controller.exception;

import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;

@Component
public class ExceptionResolver extends DataFetcherExceptionResolverAdapter {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eyakauleva nice solution

since GraphQL always throws 200 HTTP status code it's very inconvenient to show errors to the end user

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h1alexbel GraphQL still returns 200 HTTP status code. This resolver sets status code only as a field to a response body and also sets custom error message there.
As I found out, it's a GraphQL standard to always return 200 OK in such cases, cause non-200 response codes indicate that some issue occurred at the HTTP transport layer, not the GraphQL layer.
So I'm not sure whether it's a good practice to replace GraphQL HTTP status code and whether it's possible at all.


@Override
protected GraphQLError resolveToSingleError(
final Throwable ex, final DataFetchingEnvironment env
) {
return GraphqlErrorBuilder.newError()
.errorType(ErrorType.NOT_FOUND)
.message(ex.getMessage())
.path(env.getExecutionStepInfo().getPath())
.location(env.getField().getSourceLocation())
.build();
}

}
17 changes: 17 additions & 0 deletions src/main/java/com/solvd/micro9/users/web/mapper/EsMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.solvd.micro9.users.web.mapper;

import com.solvd.micro9.users.domain.es.EsUser;
import com.solvd.micro9.users.web.dto.EsDto;
import org.mapstruct.Mapper;
import reactor.core.publisher.Mono;

@Mapper(componentModel = "spring")
public interface EsMapper {

EsDto domainToDto(EsUser esUser);

default Mono<EsDto> domainToDto(Mono<EsUser> esUserMono) {
return esUserMono.map(this::domainToDto);
}

}
3 changes: 3 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ logging:
name: logs/application-info.log

spring:
graphql:
graphiql:
enabled: true
config:
import: optional:file:.env[.properties]
jackson:
Expand Down
83 changes: 83 additions & 0 deletions src/main/resources/graphql/schema.graphqls
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
scalar LocalDateTime

directive @NotBlank(message : String = "graphql.validation.NotBlank.message")
on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
directive @Min(value : Int = 0, message : String = "graphql.validation.Min.message")
on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION

type Query {
getAllUsers: [User]
findUserById(userId: ID!): User
findByCriteria(criteria: CriteriaInput!, size: Int! @Min(value: 1), page: Int! @Min): [User]
}

type Mutation {
createUser(user: UserInput!): Es
deleteUser(id: ID!): Es
}

type User {
id: ID
firstName: String!
lastName: String!
email: String!
phone: String!
age: Int
gender: Gender
height: Float
weight: Float
eyesColor: EyesColor
startStudyYear: Int
endStudyYear: Int
}

enum Gender {
MALE
FEMALE
UNSET
}

enum EyesColor {
BLUE
GREEN
BROWN
GREY
UNSET
}

input UserInput {
firstName: String @NotBlank
lastName: String @NotBlank
email: String @NotBlank
phone: String @NotBlank
age: Int
gender: Gender
height: Float
weight: Float
eyesColor: EyesColor
startStudyYear: Int
endStudyYear: Int
}

input CriteriaInput {
name: String
phone: String
age: Int
heightFrom: Float
heightTo: Float
weightFrom: Float
weightTo: Float
genders: [Gender]
eyesColors: [EyesColor]
studyYear: Int
}

type Es {
id: ID
type: String
time: LocalDateTime
createdBy: String
entityId: String
payload: String
status: String
}