diff --git a/Dockerfile-java b/Dockerfile-java new file mode 100644 index 0000000000..38fc70760f --- /dev/null +++ b/Dockerfile-java @@ -0,0 +1,12 @@ +FROM openjdk:11-jdk as BUILD_IMAGE +COPY ./packages/server-java ./packages/server-java +COPY ./modules ./modules + +WORKDIR ./packages/server-java +RUN ./gradlew clean bootJar + +FROM openjdk:11-jre-slim +WORKDIR /server-java/ +COPY --from=BUILD_IMAGE /packages/server-java/app/build/libs/app-release.jar . +EXPOSE 8080 +CMD ["java", "-jar", "app-release.jar"] \ No newline at end of file diff --git a/docker-compose.react-java.yml b/docker-compose.react-java.yml new file mode 100644 index 0000000000..6099177e70 --- /dev/null +++ b/docker-compose.react-java.yml @@ -0,0 +1,48 @@ +version: '3' +services: + apollo_java_server: + build: + context: . + dockerfile: Dockerfile-java + environment: + - JAVA_ENV=development + container_name: apollo_java_server + volumes: + - ./:${APP_DIR} + - ./modules:${APP_DIR}/modules + - ./packages/server-java/gradle/wrapper:${APP_DIR}/packages/server-java/gradle/wrapper + - ./packages/server-java/gradlew:${APP_DIR}/packages/server-java/gradlew + ports: + - 8080:8080 + stdin_open: true + tty: true + network_mode: host + + apollo_client: + build: + context: . + dockerfile: Dockerfile + args: + APP_DIR: ${APP_DIR} + depends_on: + - apollo_java_server + environment: + - NODE_ENV=development + - SERVER_HOST=apollo_java_server:8080 + container_name: apollo_client + tty: true + stdin_open: true + volumes: + - ./:${APP_DIR} + - ${APP_DIR}/build + - client_node_modules:${APP_DIR}/node_modules + working_dir: ${APP_DIR} + user: node + command: > + sh -c 'cmp -s yarn.lock node_modules/yarn.lock || yarn install --frozen-lockfile && cp -f yarn.lock node_modules/yarn.lock && yarn watch-client' + ports: + - 3000:3000 + network_mode: host + +volumes: + client_node_modules: diff --git a/modules/authentication/server-java/build.gradle b/modules/authentication/server-java/build.gradle new file mode 100644 index 0000000000..943905c9d8 --- /dev/null +++ b/modules/authentication/server-java/build.gradle @@ -0,0 +1,7 @@ +dependencies { + implementation project(':core') + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' + api 'org.springframework.boot:spring-boot-starter-security' +} \ No newline at end of file diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/config/AuthConfig.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/config/AuthConfig.java new file mode 100644 index 0000000000..c4bc32017a --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/config/AuthConfig.java @@ -0,0 +1,15 @@ +package com.sysgears.authentication.config; + +import io.jsonwebtoken.security.Keys; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.security.Key; + +@Configuration +public class AuthConfig { + @Bean + public Key jwtSecretKey(JwtConfig config) { + return Keys.hmacShaKeyFor(config.getSecret().getBytes()); + } +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/config/JwtConfig.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/config/JwtConfig.java new file mode 100644 index 0000000000..eb1d3621af --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/config/JwtConfig.java @@ -0,0 +1,16 @@ +package com.sysgears.authentication.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties("jwt") +public class JwtConfig { + private String secret; + private long accessTokenExpirationInSec; + private long refreshTokenExpirationInSec; +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/exception/RefreshTokenInvalidException.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/exception/RefreshTokenInvalidException.java new file mode 100644 index 0000000000..fdf8cf6dfb --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/exception/RefreshTokenInvalidException.java @@ -0,0 +1,11 @@ +package com.sysgears.authentication.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +public class RefreshTokenInvalidException extends RuntimeException { + + public RefreshTokenInvalidException() { + super("Refresh token is invalid."); + } +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/model/jwt/JwtUserIdentity.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/model/jwt/JwtUserIdentity.java new file mode 100644 index 0000000000..8407817912 --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/model/jwt/JwtUserIdentity.java @@ -0,0 +1,17 @@ +package com.sysgears.authentication.model.jwt; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class JwtUserIdentity { + private final int id; + private final String username; + private final String passwordHash; + private final String role; + private final Boolean isActive; + private final String email; + private final String firstName; + private final String lastName; +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/model/jwt/Tokens.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/model/jwt/Tokens.java new file mode 100644 index 0000000000..33e12a6b97 --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/model/jwt/Tokens.java @@ -0,0 +1,13 @@ +package com.sysgears.authentication.model.jwt; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Tokens { + private String accessToken; + private String refreshToken; +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/jwt/JwtMutationResolver.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/jwt/JwtMutationResolver.java new file mode 100644 index 0000000000..1eff767aa1 --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/jwt/JwtMutationResolver.java @@ -0,0 +1,31 @@ +package com.sysgears.authentication.resolvers.jwt; + +import com.sysgears.authentication.exception.RefreshTokenInvalidException; +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.authentication.model.jwt.Tokens; +import com.sysgears.authentication.service.jwt.JwtGenerator; +import com.sysgears.authentication.service.jwt.JwtParser; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class JwtMutationResolver implements GraphQLMutationResolver { + private final JwtParser jwtParser; + private final JwtGenerator jwtGenerator; + private final JwtUserIdentityService userIdentityService; + + public CompletableFuture refreshTokens(String refreshToken) { + return CompletableFuture.supplyAsync(() -> { + if (refreshToken.isBlank()) { + throw new IllegalArgumentException(); + } + Integer userId = jwtParser.getIdFromRefreshToken(refreshToken); + JwtUserIdentity userIdentity = userIdentityService.findById(userId).orElseThrow(RefreshTokenInvalidException::new); + return jwtGenerator.generateTokens(userIdentity); + }); + } +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/jwt/JwtUserIdentityService.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/jwt/JwtUserIdentityService.java new file mode 100644 index 0000000000..1f47c256e7 --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/jwt/JwtUserIdentityService.java @@ -0,0 +1,9 @@ +package com.sysgears.authentication.resolvers.jwt; + +import com.sysgears.authentication.model.jwt.JwtUserIdentity; + +import java.util.Optional; + +public interface JwtUserIdentityService { + Optional findById(Integer id); +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/session/SessionMutationResolver.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/session/SessionMutationResolver.java new file mode 100644 index 0000000000..2fb46b2758 --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/resolvers/session/SessionMutationResolver.java @@ -0,0 +1,19 @@ +package com.sysgears.authentication.resolvers.session; + +import com.sysgears.authentication.utils.SessionUtils; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SessionMutationResolver implements GraphQLMutationResolver { + + public String logout() { + if (SessionUtils.SECURITY_CONTEXT.getAuthentication() != null) { + log.info("Logout user '{}'", SessionUtils.SECURITY_CONTEXT.getAuthentication().getName()); + SessionUtils.SECURITY_CONTEXT.setAuthentication(null); + } + return null; + } +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtGenerator.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtGenerator.java new file mode 100644 index 0000000000..aa5db4fb1e --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtGenerator.java @@ -0,0 +1,10 @@ +package com.sysgears.authentication.service.jwt; + +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.authentication.model.jwt.Tokens; + +public interface JwtGenerator { + Tokens generateTokens(JwtUserIdentity identity); + + String generateVerificationToken(JwtUserIdentity identity); +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtParser.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtParser.java new file mode 100644 index 0000000000..07c9564934 --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtParser.java @@ -0,0 +1,9 @@ +package com.sysgears.authentication.service.jwt; + +public interface JwtParser { + Integer getIdFromAccessToken(String token); + + Integer getIdFromRefreshToken(String token); + + Integer getIdFromVerificationToken(String token); +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtService.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtService.java new file mode 100644 index 0000000000..cb99a7ee8d --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/service/jwt/JwtService.java @@ -0,0 +1,96 @@ +package com.sysgears.authentication.service.jwt; + +import com.sysgears.authentication.config.JwtConfig; +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.authentication.model.jwt.Tokens; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JwtService implements JwtGenerator, JwtParser { + private final Key secretKey; + private final JwtConfig jwtConfig; + + public Tokens generateTokens(JwtUserIdentity identity) { + return new Tokens(generateAccessToken(identity), generateRefreshToken(identity)); + } + + public String generateVerificationToken(JwtUserIdentity identity) { + return Jwts.builder() + .setSubject(String.valueOf(identity.getId())) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(jwtConfig.getAccessTokenExpirationInSec(), ChronoUnit.SECONDS))) + .setHeaderParam("typ", "JWT") + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Integer getIdFromAccessToken(String token) { + return (Integer) Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .get("identity", Map.class) + .get("id"); + } + + public Integer getIdFromRefreshToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody() + .get("id", Integer.class); + } + + public Integer getIdFromVerificationToken(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + return Integer.parseInt(claims.getSubject()); + } + + private String generateAccessToken(JwtUserIdentity identity) { + Map claims = new HashMap<>(); + claims.put("identity", identity); + log.debug("Generating new access JWT for user {}", identity.getId()); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(jwtConfig.getAccessTokenExpirationInSec(), ChronoUnit.SECONDS))) + .setHeaderParam("typ", "JWT") + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + private String generateRefreshToken(JwtUserIdentity identity) { + Map claims = new HashMap<>(); + claims.put("id", identity.getId()); + log.debug("Generating new refresh JWT for user {}", identity.getId()); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(Date.from(Instant.now().plus(jwtConfig.getRefreshTokenExpirationInSec(), ChronoUnit.SECONDS))) + .setHeaderParam("typ", "JWT") + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } +} diff --git a/modules/authentication/server-java/src/main/java/com/sysgears/authentication/utils/SessionUtils.java b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/utils/SessionUtils.java new file mode 100644 index 0000000000..f4bf04af0c --- /dev/null +++ b/modules/authentication/server-java/src/main/java/com/sysgears/authentication/utils/SessionUtils.java @@ -0,0 +1,12 @@ +package com.sysgears.authentication.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class SessionUtils { + public static final SecurityContext SECURITY_CONTEXT = SecurityContextHolder.createEmptyContext(); + +} diff --git a/modules/authentication/server-java/src/main/resources/jwt/schema.graphqls b/modules/authentication/server-java/src/main/resources/jwt/schema.graphqls new file mode 100644 index 0000000000..c751942f1b --- /dev/null +++ b/modules/authentication/server-java/src/main/resources/jwt/schema.graphqls @@ -0,0 +1,4 @@ +extend type Mutation { + # Refresh user tokens + refreshTokens(refreshToken: String!): Tokens! +} diff --git a/modules/authentication/server-java/src/main/resources/session/schema.graphqls b/modules/authentication/server-java/src/main/resources/session/schema.graphqls new file mode 100644 index 0000000000..59009da1c5 --- /dev/null +++ b/modules/authentication/server-java/src/main/resources/session/schema.graphqls @@ -0,0 +1,4 @@ +extend type Mutation { + # Logout user + logout: String +} diff --git a/modules/authentication/server-java/src/test/java/com/sysgears/authentication/resolvers/jwt/JwtMutationResolverTest.java b/modules/authentication/server-java/src/test/java/com/sysgears/authentication/resolvers/jwt/JwtMutationResolverTest.java new file mode 100644 index 0000000000..9e24ac6201 --- /dev/null +++ b/modules/authentication/server-java/src/test/java/com/sysgears/authentication/resolvers/jwt/JwtMutationResolverTest.java @@ -0,0 +1,62 @@ +package com.sysgears.authentication.resolvers.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.authentication.model.jwt.Tokens; +import com.sysgears.authentication.service.jwt.JwtGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class JwtMutationResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private JwtGenerator jwtGenerator; + @Autowired + private JwtUserIdentityService userService; + + @Test + void refreshTokens() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + JwtUserIdentity jwtUserIdentity = userService.findById(1).get(); + + Tokens tokens = jwtGenerator.generateTokens(jwtUserIdentity); + node.put("refreshToken", tokens.getRefreshToken()); + + + GraphQLResponse response = template.perform("refresh-tokens.graphql", node); + + assertTrue(response.isOk()); + assertNotNull(response.get("$.data.refreshTokens", Tokens.class)); + } + + @Test + void refreshTokens_user_not_found() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + JwtUserIdentity jwtUserIdentity = JwtUserIdentity.builder() + .id(55555) + .build(); + + Tokens tokens = jwtGenerator.generateTokens(jwtUserIdentity); + node.put("refreshToken", tokens.getRefreshToken()); + + + GraphQLResponse response = template.perform("refresh-tokens.graphql", node); + + assertTrue(response.isOk()); + assertEquals("Refresh token is invalid.", response.get("$.errors[0].message")); + } +} diff --git a/modules/authentication/server-java/src/test/java/com/sysgears/authentication/resolvers/session/SessionMutationResolverTest.java b/modules/authentication/server-java/src/test/java/com/sysgears/authentication/resolvers/session/SessionMutationResolverTest.java new file mode 100644 index 0000000000..e7ba43f147 --- /dev/null +++ b/modules/authentication/server-java/src/test/java/com/sysgears/authentication/resolvers/session/SessionMutationResolverTest.java @@ -0,0 +1,32 @@ +package com.sysgears.authentication.resolvers.session; + +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.authentication.utils.SessionUtils; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class SessionMutationResolverTest { + @Autowired + private GraphQLTestTemplate template; + + @Test + void logout() throws IOException { + SessionUtils.SECURITY_CONTEXT.setAuthentication(new UsernamePasswordAuthenticationToken("username", "password")); + GraphQLResponse response = template.postForResource("logout.graphql"); + + assertTrue(response.isOk()); + assertNull(SessionUtils.SECURITY_CONTEXT.getAuthentication()); + assertNull(response.get("$.data.logout")); + } +} diff --git a/modules/authentication/server-java/src/test/resources/logout.graphql b/modules/authentication/server-java/src/test/resources/logout.graphql new file mode 100644 index 0000000000..1b6779f833 --- /dev/null +++ b/modules/authentication/server-java/src/test/resources/logout.graphql @@ -0,0 +1,3 @@ +mutation logout { + logout +} diff --git a/modules/authentication/server-java/src/test/resources/refresh-tokens.graphql b/modules/authentication/server-java/src/test/resources/refresh-tokens.graphql new file mode 100644 index 0000000000..0f5135fe26 --- /dev/null +++ b/modules/authentication/server-java/src/test/resources/refresh-tokens.graphql @@ -0,0 +1,6 @@ +mutation refreshTokens($refreshToken: String!) { + refreshTokens(refreshToken: $refreshToken) { + accessToken + refreshToken + } +} diff --git a/modules/chat/server-java/build.gradle b/modules/chat/server-java/build.gradle new file mode 100644 index 0000000000..60cceb3f67 --- /dev/null +++ b/modules/chat/server-java/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation project(':core') + implementation project(':upload') + implementation project(':user') +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/config/JacksonConfig.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/config/JacksonConfig.java new file mode 100644 index 0000000000..e205b7134f --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/config/JacksonConfig.java @@ -0,0 +1,32 @@ +package com.sysgears.chat.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.servlet.http.Part; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Part.class, new PartDeserializer()); + objectMapper.registerModule(module); + return objectMapper; + } + + // Mock deserializer for Part to use Part in DTO as in graphQL input + public static class PartDeserializer extends JsonDeserializer { + @Override + public Part deserialize(JsonParser p, DeserializationContext ctxt) { + return null; + } + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessageEdges.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessageEdges.java new file mode 100644 index 0000000000..68d081f260 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessageEdges.java @@ -0,0 +1,15 @@ +package com.sysgears.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MessageEdges { + private MessagePayload node; + private Integer cursor; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessagePageInfo.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessagePageInfo.java new file mode 100644 index 0000000000..e933c42dc7 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessagePageInfo.java @@ -0,0 +1,15 @@ +package com.sysgears.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MessagePageInfo { + private Integer endCursor; + private Boolean hasNextPage; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessagePayload.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessagePayload.java new file mode 100644 index 0000000000..e492cae5eb --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/MessagePayload.java @@ -0,0 +1,51 @@ +package com.sysgears.chat.dto; + +import com.sysgears.chat.model.Message; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MessagePayload { + @NonNull + private Integer id; + private String text; + private Integer userId; + private String createdAt; + private String username; + private String uuid; + private Integer quotedId; + private String filename; + private String path; + private QuotedMessage quotedMessage; + + public static MessagePayload from(Message message) { + QuotedMessage quotedMessage = new QuotedMessage(); + if (message.getQuoted() != null) { + quotedMessage.setId(message.getQuoted().getId()); + quotedMessage.setText(message.getQuoted().getText()); + if (message.getQuoted().getUser() != null) { + quotedMessage.setUsername(message.getQuoted().getUser().getUsername()); + } + if (message.getQuoted().getAttachment() != null) { + quotedMessage.setFilename(message.getQuoted().getAttachment().getName()); + quotedMessage.setPath(message.getQuoted().getAttachment().getPath()); + } + } + return new MessagePayload( + message.getId(), + message.getText(), + message.getUser() != null ? message.getUser().getId() : null, + message.getCreatedAt().toString(), + message.getUser() != null ? message.getUser().getUsername() : null, + message.getUuid().toString(), + quotedMessage.getId(), + message.getAttachment() != null ? message.getAttachment().getName() : null, + message.getAttachment() != null ? message.getAttachment().getPath() : null, + quotedMessage + ); + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/Messages.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/Messages.java new file mode 100644 index 0000000000..e9ca593952 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/Messages.java @@ -0,0 +1,18 @@ +package com.sysgears.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Messages { + private Long totalCount; + private List edges; + private MessagePageInfo pageInfo; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/QuotedMessage.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/QuotedMessage.java new file mode 100644 index 0000000000..0380032605 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/QuotedMessage.java @@ -0,0 +1,14 @@ +package com.sysgears.chat.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class QuotedMessage { + private Integer id; + private String text; + private String username; + private String filename; + private String path; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/input/AddMessageInput.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/input/AddMessageInput.java new file mode 100644 index 0000000000..352e72fdaa --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/input/AddMessageInput.java @@ -0,0 +1,16 @@ +package com.sysgears.chat.dto.input; + +import lombok.*; + +import javax.servlet.http.Part; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AddMessageInput { + private String text; + private Integer userId; + private String uuid; + private Integer quotedId; + private Part attachment; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/input/EditMessageInput.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/input/EditMessageInput.java new file mode 100644 index 0000000000..e32c94221c --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/input/EditMessageInput.java @@ -0,0 +1,16 @@ +package com.sysgears.chat.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EditMessageInput { + @NonNull + private Integer id; + private String text; + private Integer userId; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/subscription/UpdateMessagesPayload.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/subscription/UpdateMessagesPayload.java new file mode 100644 index 0000000000..ad60f8ef69 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/dto/subscription/UpdateMessagesPayload.java @@ -0,0 +1,14 @@ +package com.sysgears.chat.dto.subscription; + +import com.sysgears.chat.dto.MessagePayload; +import lombok.Data; +import org.springframework.lang.NonNull; + +@Data +public class UpdateMessagesPayload { + @NonNull + private final String mutation; + private final Integer id; + @NonNull + private final MessagePayload node; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/exception/MessageNotFoundException.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/exception/MessageNotFoundException.java new file mode 100644 index 0000000000..ca3fdbdd04 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/exception/MessageNotFoundException.java @@ -0,0 +1,8 @@ +package com.sysgears.chat.exception; + +public class MessageNotFoundException extends RuntimeException { + + public MessageNotFoundException(Integer id) { + super(String.format("Message with id %d not found", id)); + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/model/Message.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/model/Message.java new file mode 100644 index 0000000000..9e9f6745d2 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/model/Message.java @@ -0,0 +1,50 @@ +package com.sysgears.chat.model; + +import com.sysgears.upload.model.FileMetadata; +import com.sysgears.user.model.User; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.GenericGenerator; +import org.springframework.data.annotation.CreatedDate; + +import javax.persistence.*; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "MESSAGE") +public class Message { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "TEXT") + private String text; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false) + private final Instant createdAt = Instant.now(); + + @ManyToOne + @Fetch(FetchMode.JOIN) + @JoinColumn(name = "USER_ID") + private User user; + + @Column(name = "UUID") + private UUID uuid; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "QUOTED_ID") + private Message quoted; + + @OneToOne + @Fetch(FetchMode.JOIN) + @JoinColumn(name = "ATTACHMENT_ID") + private FileMetadata attachment; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/repository/MessageRepository.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/repository/MessageRepository.java new file mode 100644 index 0000000000..4f897e86a1 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/repository/MessageRepository.java @@ -0,0 +1,12 @@ +package com.sysgears.chat.repository; + +import com.sysgears.chat.model.Message; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.scheduling.annotation.Async; + +import java.util.concurrent.CompletableFuture; + +public interface MessageRepository extends JpaRepository { + @Async + CompletableFuture findMessageById(Integer id); +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageMutationResolver.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageMutationResolver.java new file mode 100644 index 0000000000..421d8f4edf --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageMutationResolver.java @@ -0,0 +1,101 @@ +package com.sysgears.chat.resolvers; + +import com.sysgears.chat.dto.MessagePayload; +import com.sysgears.chat.dto.input.AddMessageInput; +import com.sysgears.chat.dto.input.EditMessageInput; +import com.sysgears.chat.model.Message; +import com.sysgears.chat.service.MessageService; +import com.sysgears.chat.subscription.MessageUpdatedEvent; +import com.sysgears.chat.subscription.Mutation; +import com.sysgears.core.subscription.Publisher; +import com.sysgears.upload.model.FileMetadata; +import com.sysgears.upload.service.FileService; +import com.sysgears.user.model.User; +import com.sysgears.user.service.UserService; +import graphql.kickstart.tools.GraphQLMutationResolver; +import graphql.schema.DataFetchingEnvironment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.servlet.http.Part; +import java.util.LinkedHashMap; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class MessageMutationResolver implements GraphQLMutationResolver { + private final MessageService messageService; + private final FileService fileService; + private final Publisher messagePublisher; + private final UserService userService; + + + public CompletableFuture addMessage(AddMessageInput input, DataFetchingEnvironment environment) { + return CompletableFuture.supplyAsync(() -> { + Message message = new Message(); + if (input.getUserId() != null) { + User user = userService.findUserById(input.getUserId()).join(); + message.setUser(user); + } else { + userService.getCurrentAuditor().ifPresent(message::setUser); + } + if (input.getQuotedId() != null) { + Message quotedMessage = messageService.findById(input.getQuotedId()); + message.setQuoted(quotedMessage); + } + + message.setText(input.getText()); + message.setUuid(UUID.fromString(input.getUuid())); + + resolveAttachment(environment).ifPresent(attachment -> { + FileMetadata fileMetadata = fileService.create(attachment); + message.setAttachment(fileMetadata); + }); + + MessagePayload created = messageService.create(message); + + messagePublisher.publish(new MessageUpdatedEvent(Mutation.CREATED, created)); + + return created; + }); + } + + public CompletableFuture deleteMessage(Integer id) { + return CompletableFuture.supplyAsync(() -> { + final Message deleted = messageService.deleteById(id); + if (deleted.getAttachment() != null) { + fileService.deleteById(deleted.getAttachment().getId()); + } + + final MessagePayload messagePayload = MessagePayload.from(deleted); + messagePublisher.publish(new MessageUpdatedEvent(Mutation.DELETED, messagePayload)); + + return messagePayload; + }); + } + + public CompletableFuture editMessage(EditMessageInput input) { + return CompletableFuture.supplyAsync(() -> { + final Message message = messageService.findById(input.getId()); + message.setText(input.getText()); + if (input.getUserId() != null) { + User user = userService.findUserById(input.getUserId()).join(); + message.setUser(user); + } else { + userService.getCurrentAuditor().ifPresent(message::setUser); + } + final MessagePayload updated = messageService.update(message); + + messagePublisher.publish(new MessageUpdatedEvent(Mutation.UPDATED, updated)); + + return updated; + }); + } + + private Optional resolveAttachment(DataFetchingEnvironment environment) { + final LinkedHashMap inputMap = environment.getArgument("input"); + return Optional.ofNullable((Part) inputMap.get("attachment")); + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageQueryResolver.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageQueryResolver.java new file mode 100644 index 0000000000..c2765f78a3 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageQueryResolver.java @@ -0,0 +1,33 @@ +package com.sysgears.chat.resolvers; + +import com.sysgears.chat.dto.MessagePayload; +import com.sysgears.chat.dto.Messages; +import com.sysgears.chat.service.MessageService; +import com.sysgears.core.pagination.OffsetPageRequest; +import graphql.kickstart.tools.GraphQLQueryResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class MessageQueryResolver implements GraphQLQueryResolver { + private final MessageService messageService; + + public CompletableFuture messages(Optional limit, Optional after) { + return CompletableFuture.supplyAsync(() -> { + int offset = after.orElse(0); + int size = limit.orElse(50); + Pageable pageRequest = new OffsetPageRequest(offset, size); + + return messageService.findAll(pageRequest); + }); + } + + public CompletableFuture message(Integer id) { + return messageService.getById(id); + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageSubscriptionResolver.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageSubscriptionResolver.java new file mode 100644 index 0000000000..bf10430602 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/resolvers/MessageSubscriptionResolver.java @@ -0,0 +1,22 @@ +package com.sysgears.chat.resolvers; + +import com.sysgears.chat.dto.subscription.UpdateMessagesPayload; +import com.sysgears.chat.subscription.MessageUpdatedEvent; +import com.sysgears.core.subscription.Subscriber; +import graphql.kickstart.tools.GraphQLSubscriptionResolver; +import lombok.RequiredArgsConstructor; +import org.reactivestreams.Publisher; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class MessageSubscriptionResolver implements GraphQLSubscriptionResolver { + private final Subscriber messageSubscriber; + + public CompletableFuture> messagesUpdated(Integer endCursor) { + return CompletableFuture.supplyAsync(() -> messageSubscriber.subscribe(e -> e.getMessage().getId() <= endCursor) + .map(event -> new UpdateMessagesPayload(event.getMutation().name(), event.getMessage().getId(), event.getMessage()))); + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/service/MessageService.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/service/MessageService.java new file mode 100644 index 0000000000..2fbb1223f9 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/service/MessageService.java @@ -0,0 +1,82 @@ +package com.sysgears.chat.service; + +import com.sysgears.chat.dto.*; +import com.sysgears.chat.exception.MessageNotFoundException; +import com.sysgears.chat.model.Message; +import com.sysgears.chat.repository.MessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +@Transactional +public class MessageService { + private final MessageRepository repository; + + @Transactional(readOnly = true) + public Messages findAll(Pageable pageable) { + Page messages = repository.findAll(pageable); + + List messageEdgesList = new ArrayList<>(); + + + for (int i = 0; i < messages.getContent().size(); i++) { + Message message = messages.getContent().get(i); + messageEdgesList.add( + MessageEdges.builder() + .cursor((int) (pageable.getOffset() + i)) + .node(MessagePayload.from(message)) + .build() + ); + } + + return Messages.builder() + .edges(messageEdgesList) + .pageInfo( + MessagePageInfo.builder() + .endCursor((int) (pageable.getOffset() + messages.getSize() - 1)) + .hasNextPage(!messages.isLast()) + .build() + ) + .totalCount(messages.getTotalElements()) + .build(); + } + + @Transactional(readOnly = true) + public CompletableFuture getById(Integer id) { + return repository.findMessageById(id).thenApply(message -> { + if (message == null) throw new MessageNotFoundException(id); + + return MessagePayload.from(message); + }); + } + + @Transactional(readOnly = true) + public Message findById(Integer id) { + return repository.findById(id).orElseThrow(() -> new MessageNotFoundException(id)); + } + + public MessagePayload create(Message message) { + final Message created = repository.save(message); + return MessagePayload.from(created); + } + + public MessagePayload update(Message message) { + final Message created = repository.save(message); + return MessagePayload.from(created); + } + + public Message deleteById(Integer id) { + final Message message = findById(id); + repository.delete(message); + + return message; + } +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/MessagePubSubService.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/MessagePubSubService.java new file mode 100644 index 0000000000..9c7e474bf4 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/MessagePubSubService.java @@ -0,0 +1,8 @@ +package com.sysgears.chat.subscription; + +import com.sysgears.core.subscription.AbstractPubSubService; +import org.springframework.stereotype.Component; + +@Component +public class MessagePubSubService extends AbstractPubSubService { +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/MessageUpdatedEvent.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/MessageUpdatedEvent.java new file mode 100644 index 0000000000..66366761e8 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/MessageUpdatedEvent.java @@ -0,0 +1,14 @@ +package com.sysgears.chat.subscription; + +import com.sysgears.chat.dto.MessagePayload; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MessageUpdatedEvent { + private Mutation mutation; + private MessagePayload message; +} diff --git a/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/Mutation.java b/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/Mutation.java new file mode 100644 index 0000000000..25cb0255b9 --- /dev/null +++ b/modules/chat/server-java/src/main/java/com/sysgears/chat/subscription/Mutation.java @@ -0,0 +1,5 @@ +package com.sysgears.chat.subscription; + +public enum Mutation { + DELETED, UPDATED, CREATED +} diff --git a/modules/chat/server-java/src/main/resources/schema.graphqls b/modules/chat/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..873c58a2b6 --- /dev/null +++ b/modules/chat/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,87 @@ +scalar FileUpload + +type QuotedMessage { + id: Int + text: String + username: String + filename: String + path: String +} + +# Message +type Message { + id: Int! + text: String + userId: Int + createdAt: String + username: String + uuid: String + quotedId: Int + filename: String + path: String + quotedMessage: QuotedMessage +} + +# Edges for Messages +type MessageEdges { + node: Message + cursor: Int +} + +# PageInfo for Messages +type MessagePageInfo { + endCursor: Int + hasNextPage: Boolean +} + +# Messages relay-style pagination query +type Messages { + totalCount: Int + edges: [MessageEdges] + pageInfo: MessagePageInfo +} + +extend type Query { + # Messages + messages(limit: Int, after: Int): Messages + # Message + message(id: Int!): Message +} + +extend type Mutation { + # Create new message + addMessage(input: AddMessageInput!): Message + # Delete a message + deleteMessage(id: Int!): Message + # Edit a message + editMessage(input: EditMessageInput!): Message +} + +# Input for addMessage Mutation +input AddMessageInput { + text: String + userId: Int + uuid: String + quotedId: Int + attachment: FileUpload +} + +# Input for editMessage Mutation +input EditMessageInput { + id: Int! + text: String + userId: Int +} + +extend type Subscription { + # Subscription fired when anyone changes messages list + messagesUpdated(endCursor: Int!): UpdateMessagesPayload + # messagesUpdated: UpdateMessagesPayload +} + +# Payload for messagesUpdated Subscription +type UpdateMessagesPayload { + mutation: String! + id: Int + node: Message! +} diff --git a/modules/chat/server-java/src/test/java/com/sysgears/chat/resolvers/MessageMutationResolverTest.java b/modules/chat/server-java/src/test/java/com/sysgears/chat/resolvers/MessageMutationResolverTest.java new file mode 100644 index 0000000000..a756c19bf0 --- /dev/null +++ b/modules/chat/server-java/src/test/java/com/sysgears/chat/resolvers/MessageMutationResolverTest.java @@ -0,0 +1,313 @@ +package com.sysgears.chat.resolvers; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.chat.dto.MessagePayload; +import com.sysgears.chat.model.Message; +import com.sysgears.chat.service.MessageService; +import com.sysgears.user.model.User; +import com.sysgears.user.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.*; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class MessageMutationResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private MessageService messageService; + @Autowired + private TestRestTemplate restTemplate; + @MockBean + private UserService userService; + + @Test + void addMessage() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + User user = new User(); + user.setId(1); + user.setUsername("user"); + String text = "Hello"; + String uuid = UUID.randomUUID().toString(); + node.put("text", text); + node.put("userId", user.getId()); + node.put("uuid", uuid); + + input.set("input", node); + + when(userService.findUserById(user.getId())).thenReturn(CompletableFuture.completedFuture(user)); + + GraphQLResponse response = template.perform("add-message.graphql", input); + assertTrue(response.isOk()); + + MessagePayload messagePayload = response.get("$.data.addMessage", MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertEquals(text, messagePayload.getText()); + assertEquals(user.getId(), messagePayload.getUserId()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertEquals(user.getUsername(), messagePayload.getUsername()); + assertEquals(uuid, messagePayload.getUuid()); + assertNull(messagePayload.getQuotedId()); + assertNull(messagePayload.getQuotedMessage().getId()); + assertNull(messagePayload.getFilename()); + assertNull(messagePayload.getPath()); + } + + @Test + void addMessage_userId_not_specified() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + User user = new User(); + user.setId(2); + user.setUsername("test"); + String text = "Foo"; + String uuid = UUID.randomUUID().toString(); + node.put("text", text); + node.put("uuid", uuid); + + input.set("input", node); + + when(userService.getCurrentAuditor()).thenReturn(Optional.of(user)); + + GraphQLResponse response = template.perform("add-message.graphql", input); + assertTrue(response.isOk()); + + MessagePayload messagePayload = response.get("$.data.addMessage", MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertEquals(text, messagePayload.getText()); + assertEquals(user.getId(), messagePayload.getUserId()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertEquals(user.getUsername(), messagePayload.getUsername()); + assertEquals(uuid, messagePayload.getUuid()); + assertNull(messagePayload.getQuotedId()); + assertNull(messagePayload.getQuotedMessage().getId()); + assertNull(messagePayload.getFilename()); + assertNull(messagePayload.getPath()); + } + + @Test + void addMessage_from_anonymous() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String text = "bar"; + String uuid = UUID.randomUUID().toString(); + node.put("text", text); + node.put("uuid", uuid); + + input.set("input", node); + + GraphQLResponse response = template.perform("add-message.graphql", input); + assertTrue(response.isOk()); + + MessagePayload messagePayload = response.get("$.data.addMessage", MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertEquals(text, messagePayload.getText()); + assertNull(messagePayload.getUserId()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertNull(messagePayload.getUsername()); + assertEquals(uuid, messagePayload.getUuid()); + assertNull(messagePayload.getQuotedId()); + assertNull(messagePayload.getQuotedMessage().getId()); + assertNull(messagePayload.getFilename()); + assertNull(messagePayload.getPath()); + } + + @Test + void addMessage_quote() throws IOException { + Message message = new Message(); + message.setUuid(UUID.randomUUID()); + message.setText("foo bar"); + messageService.create(message); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String text = "baz"; + String uuid = UUID.randomUUID().toString(); + node.put("text", text); + node.put("quotedId", message.getId()); + node.put("uuid", uuid); + + input.set("input", node); + + GraphQLResponse response = template.perform("add-message.graphql", input); + assertTrue(response.isOk()); + + MessagePayload messagePayload = response.get("$.data.addMessage", MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertEquals(text, messagePayload.getText()); + assertNull(messagePayload.getUserId()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertNull(messagePayload.getUsername()); + assertEquals(uuid, messagePayload.getUuid()); + assertEquals(message.getId(), messagePayload.getQuotedId()); + assertEquals(message.getId(), messagePayload.getQuotedMessage().getId()); + assertEquals(message.getText(), messagePayload.getQuotedMessage().getText()); + assertNull(messagePayload.getFilename()); + assertNull(messagePayload.getPath()); + } + + @Test + void deleteMessage() throws IOException { + Message message = new Message(); + message.setUuid(UUID.randomUUID()); + message.setText("foo bar"); + messageService.create(message); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", message.getId()); + + GraphQLResponse response = template.perform("delete-message.graphql", node); + assertTrue(response.isOk()); + + MessagePayload messagePayload = response.get("$.data.deleteMessage", MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertEquals(message.getText(), messagePayload.getText()); + assertNull(messagePayload.getUserId()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertNull(messagePayload.getUsername()); + assertEquals(message.getUuid().toString(), messagePayload.getUuid()); + assertNull(messagePayload.getQuotedId()); + assertNull(messagePayload.getQuotedMessage().getId()); + assertNull(messagePayload.getFilename()); + assertNull(messagePayload.getPath()); + } + + @Test + void deleteMessage_not_exists() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + int invalidMessageId = 5555; + node.put("id", invalidMessageId); + + GraphQLResponse response = template.perform("delete-message.graphql", node); + assertTrue(response.isOk()); + assertEquals(String.format("Message with id %d not found", invalidMessageId), response.get("$.errors[0].message")); + } + + @Test + void editMessage() throws IOException { + Message message = new Message(); + message.setUuid(UUID.randomUUID()); + message.setText("foo bar"); + messageService.create(message); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String text = "baz"; + node.put("id", message.getId()); + node.put("text", text); + + input.set("input", node); + + GraphQLResponse response = template.perform("edit-message.graphql", input); + assertTrue(response.isOk()); + + MessagePayload messagePayload = response.get("$.data.editMessage", MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertEquals(text, messagePayload.getText()); + assertNull(messagePayload.getUserId()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertNull(messagePayload.getUsername()); + assertEquals(message.getUuid().toString(), messagePayload.getUuid()); + assertNull(messagePayload.getQuotedId()); + assertNull(messagePayload.getQuotedMessage().getId()); + assertNull(messagePayload.getFilename()); + assertNull(messagePayload.getPath()); + } + + @Test + void editMessage_not_exists() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + int invalidMessageId = 2414; + String text = "new message"; + node.put("id", invalidMessageId); + node.put("text", text); + + input.set("input", node); + + GraphQLResponse response = template.perform("edit-message.graphql", input); + assertTrue(response.isOk()); + assertEquals(String.format("Message with id %d not found", invalidMessageId), response.get("$.errors[0].message")); + } + + @Test + void addMessage_with_attachment() throws Exception { + FileWriter writer = new FileWriter("filename.txt"); + writer.write("some file content"); + writer.close(); + File file = new File("filename.txt"); + + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("operations", "{\"query\":\"mutation addMessage($input: AddMessageInput!) { addMessage(input: $input) { id text userId createdAt username uuid quotedId filename path quotedMessage { id text username filename path }}}\",\"variables\":{ \"input\": {\"text\": \"rr\", \"uuid\": \"83f92165-f0c6-40c1-9764-6325e9548799\", \"attachment\": null}}}"); + params.add("map", "{\"0\": [\"variables.input.attachment\"]}"); + params.add("0", new FileSystemResource(file)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + ResponseEntity response = restTemplate.exchange( + "/graphql", + HttpMethod.POST, + new HttpEntity<>(params, headers), + String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(response.getBody()); + MessagePayload messagePayload = objectMapper.readValue(jsonNode.findPath("addMessage").traverse(), MessagePayload.class); + + assertNotNull(messagePayload.getId()); + assertNotNull(messagePayload.getText()); + assertFalse(messagePayload.getCreatedAt().isBlank()); + assertEquals(file.getName(), messagePayload.getFilename()); + assertEquals("files/" + file.getName(), messagePayload.getPath()); + + Files.deleteIfExists(file.toPath()); + Files.deleteIfExists(new File(messagePayload.getPath()).toPath()); + } +} diff --git a/modules/chat/server-java/src/test/java/com/sysgears/chat/resolvers/MessageQueryResolverTest.java b/modules/chat/server-java/src/test/java/com/sysgears/chat/resolvers/MessageQueryResolverTest.java new file mode 100644 index 0000000000..f0529a2446 --- /dev/null +++ b/modules/chat/server-java/src/test/java/com/sysgears/chat/resolvers/MessageQueryResolverTest.java @@ -0,0 +1,123 @@ +package com.sysgears.chat.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.chat.dto.MessageEdges; +import com.sysgears.chat.dto.MessagePayload; +import com.sysgears.chat.dto.Messages; +import com.sysgears.chat.model.Message; +import com.sysgears.chat.repository.MessageRepository; +import com.sysgears.chat.service.MessageService; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class MessageQueryResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private MessageRepository messageRepository; + + private List messageList; + @BeforeEach + void init() { + messageRepository.deleteAll(); + messageList = prepare(); + } + + @Test + void messages_with_default_limit() throws IOException { + GraphQLResponse response = template.postForResource("get-messages.graphql"); + + assertTrue(response.isOk()); + Messages messages = response.get("$.data.messages", Messages.class); + assertEquals(messageList.size(), messages.getTotalCount()); + assertEquals(49, messages.getPageInfo().getEndCursor()); + assertFalse(messages.getPageInfo().getHasNextPage()); + + List expected = messageList.stream().map(MessagePayload::from).collect(Collectors.toList()); + List actual = messages.getEdges().stream().map(MessageEdges::getNode).collect(Collectors.toList()); + Assertions.assertThat(actual).containsExactlyInAnyOrder(expected.toArray(MessagePayload[]::new)); + } + + @Test + void messages_with_custom_limit() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("limit", 5); + node.put("after", 0); + + GraphQLResponse response = template.perform("get-messages.graphql", node); + + assertTrue(response.isOk()); + Messages messages = response.get("$.data.messages", Messages.class); + assertEquals(messageList.size(), messages.getTotalCount()); + assertEquals(4, messages.getPageInfo().getEndCursor()); + assertTrue(messages.getPageInfo().getHasNextPage()); + + List expected = messageList.subList(0, 5).stream().map(MessagePayload::from).collect(Collectors.toList()); + List actual = messages.getEdges().stream().map(MessageEdges::getNode).collect(Collectors.toList()); + Assertions.assertThat(actual).containsExactlyInAnyOrder(expected.toArray(MessagePayload[]::new)); + } + + @Test + void message() throws IOException { + Message expectedMessage = messageList.get(0); + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", expectedMessage.getId()); + + GraphQLResponse response = template.perform("get-message.graphql", node); + + assertTrue(response.isOk()); + MessagePayload messagePayload = response.get("$.data.message", MessagePayload.class); + assertEquals(expectedMessage.getId(), messagePayload.getId()); + assertEquals(expectedMessage.getText(), messagePayload.getText()); + assertEquals(expectedMessage.getUuid().toString(), messagePayload.getUuid()); + } + + @Test + void message_not_exists() throws IOException { + Message expectedMessage = messageList.get(0); + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", expectedMessage.getId()); + + GraphQLResponse response = template.perform("get-message.graphql", node); + + assertTrue(response.isOk()); + MessagePayload messagePayload = response.get("$.data.message", MessagePayload.class); + assertEquals(expectedMessage.getId(), messagePayload.getId()); + assertEquals(expectedMessage.getText(), messagePayload.getText()); + assertEquals(expectedMessage.getUuid().toString(), messagePayload.getUuid()); + } + + private List prepare() { + List messages = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Message message = new Message(); + message.setText("message " + i); + message.setUuid(UUID.randomUUID()); + messages.add(message); + } + return messageRepository.saveAll(messages); + } +} diff --git a/modules/chat/server-java/src/test/resources/add-message.graphql b/modules/chat/server-java/src/test/resources/add-message.graphql new file mode 100644 index 0000000000..ba26a5393a --- /dev/null +++ b/modules/chat/server-java/src/test/resources/add-message.graphql @@ -0,0 +1,20 @@ +mutation addMessage($input: AddMessageInput!) { + addMessage(input: $input) { + id + text + userId + createdAt + username + uuid + quotedId + filename + path + quotedMessage { + id + text + username + filename + path + } + } +} diff --git a/modules/chat/server-java/src/test/resources/delete-message.graphql b/modules/chat/server-java/src/test/resources/delete-message.graphql new file mode 100644 index 0000000000..1659cf1ab9 --- /dev/null +++ b/modules/chat/server-java/src/test/resources/delete-message.graphql @@ -0,0 +1,20 @@ +mutation deleteMessage($id: Int!) { + deleteMessage(id: $id) { + id + text + userId + createdAt + username + uuid + quotedId + filename + path + quotedMessage { + id + text + username + filename + path + } + } +} diff --git a/modules/chat/server-java/src/test/resources/edit-message.graphql b/modules/chat/server-java/src/test/resources/edit-message.graphql new file mode 100644 index 0000000000..e191aaca3a --- /dev/null +++ b/modules/chat/server-java/src/test/resources/edit-message.graphql @@ -0,0 +1,20 @@ +mutation editMessage($input: EditMessageInput!) { + editMessage(input: $input) { + id + text + userId + createdAt + username + uuid + quotedId + filename + path + quotedMessage { + id + text + username + filename + path + } + } +} diff --git a/modules/chat/server-java/src/test/resources/get-message.graphql b/modules/chat/server-java/src/test/resources/get-message.graphql new file mode 100644 index 0000000000..6531280983 --- /dev/null +++ b/modules/chat/server-java/src/test/resources/get-message.graphql @@ -0,0 +1,20 @@ +query message($id: Int!) { + message(id: $id) { + id + text + userId + createdAt + username + uuid + quotedId + filename + path + quotedMessage { + id + text + username + filename + path + } + } +} diff --git a/modules/chat/server-java/src/test/resources/get-messages.graphql b/modules/chat/server-java/src/test/resources/get-messages.graphql new file mode 100644 index 0000000000..99c8e4fff4 --- /dev/null +++ b/modules/chat/server-java/src/test/resources/get-messages.graphql @@ -0,0 +1,30 @@ +query messages($limit: Int, $after: Int) { + messages(limit: $limit, after: $after) { + totalCount + edges { + cursor + node { + id + text + userId + createdAt + username + uuid + quotedId + filename + path + quotedMessage { + id + text + username + filename + path + } + } + } + pageInfo { + endCursor + hasNextPage + } + } +} diff --git a/modules/contact/server-java/build.gradle b/modules/contact/server-java/build.gradle new file mode 100644 index 0000000000..47d74d8978 --- /dev/null +++ b/modules/contact/server-java/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':core') + implementation project(':mailer') +} diff --git a/modules/contact/server-java/src/main/java/com/sysgears/contact/dto/ContactInput.java b/modules/contact/server-java/src/main/java/com/sysgears/contact/dto/ContactInput.java new file mode 100644 index 0000000000..bcd98a0ba1 --- /dev/null +++ b/modules/contact/server-java/src/main/java/com/sysgears/contact/dto/ContactInput.java @@ -0,0 +1,18 @@ +package com.sysgears.contact.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ContactInput { + @NonNull + private String name; + @NonNull + private String email; + @NonNull + private String content; +} diff --git a/modules/contact/server-java/src/main/java/com/sysgears/contact/resolvers/ContactMutationResolver.java b/modules/contact/server-java/src/main/java/com/sysgears/contact/resolvers/ContactMutationResolver.java new file mode 100644 index 0000000000..6d57417ee8 --- /dev/null +++ b/modules/contact/server-java/src/main/java/com/sysgears/contact/resolvers/ContactMutationResolver.java @@ -0,0 +1,22 @@ +package com.sysgears.contact.resolvers; + +import com.sysgears.contact.dto.ContactInput; +import com.sysgears.mailer.service.EmailService; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class ContactMutationResolver implements GraphQLMutationResolver { + private final EmailService emailService; + + public CompletableFuture contact(ContactInput input) { + return CompletableFuture.supplyAsync(() -> { + emailService.sendContactUsEmail(input.getName(), input.getEmail(), input.getContent()); + return null; + }); + } +} diff --git a/modules/contact/server-java/src/main/resources/schema.graphqls b/modules/contact/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..5fa8ad9679 --- /dev/null +++ b/modules/contact/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,13 @@ +# Payload for contact Mutation + +extend type Mutation { + # Send contact us info + contact(input: ContactInput!): String +} + +# Input for addPost Mutation +input ContactInput { + name: String! + email: String! + content: String! +} diff --git a/modules/core/server-java/build.gradle b/modules/core/server-java/build.gradle new file mode 100644 index 0000000000..9e196bc443 --- /dev/null +++ b/modules/core/server-java/build.gradle @@ -0,0 +1,3 @@ +dependencies { + api project(':i18n') +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/exception/CustomGraphQLException.java b/modules/core/server-java/src/main/java/com/sysgears/core/exception/CustomGraphQLException.java new file mode 100644 index 0000000000..ae99475104 --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/exception/CustomGraphQLException.java @@ -0,0 +1,52 @@ +package com.sysgears.core.exception; + +import graphql.GraphQLError; +import graphql.kickstart.execution.error.GenericGraphQLError; + +import java.util.Map; +import java.util.Objects; + +public class CustomGraphQLException extends GenericGraphQLError { + + private final Throwable throwable; + + public CustomGraphQLException(Throwable throwable) { + this(throwable, throwable.getMessage()); + } + + public CustomGraphQLException(Throwable throwable, String message) { + super(message); + + this.throwable = throwable; + } + + public String getType() { + return throwable.getClass().getSimpleName(); + } + + @Override + public Map getExtensions() { + if (throwable instanceof GraphQLError) { + return ((GraphQLError) throwable).getExtensions(); + } else { + return null; + } + } + + @Override + public final boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomGraphQLException)) { + return false; + } + CustomGraphQLException that = (CustomGraphQLException) o; + return Objects.equals(throwable, that.throwable) && Objects.equals(getMessage(), that.getMessage()); + } + + @Override + public final int hashCode() { + return Objects.hash(throwable, getMessage()); + } +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/exception/FieldErrorException.java b/modules/core/server-java/src/main/java/com/sysgears/core/exception/FieldErrorException.java new file mode 100644 index 0000000000..182180f00a --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/exception/FieldErrorException.java @@ -0,0 +1,33 @@ +package com.sysgears.core.exception; + +import graphql.ErrorClassification; +import graphql.GraphQLError; +import graphql.language.SourceLocation; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public abstract class FieldErrorException extends RuntimeException implements GraphQLError { + private final Map errors = new HashMap<>(); + + public FieldErrorException(String message, Map errors) { + super(message); + this.errors.putAll(errors); + } + + @Override + public List getLocations() { + return null; + } + + @Override + public ErrorClassification getErrorType() { + return null; + } + + @Override + public Map getExtensions() { + return Map.of("exception", Map.of("errors", errors)); + } +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/exception/GraphQLExceptionHandler.java b/modules/core/server-java/src/main/java/com/sysgears/core/exception/GraphQLExceptionHandler.java new file mode 100644 index 0000000000..615b48ee23 --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/exception/GraphQLExceptionHandler.java @@ -0,0 +1,21 @@ +package com.sysgears.core.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.concurrent.CompletionException; + +@Slf4j +@Component +public class GraphQLExceptionHandler { + + @ExceptionHandler(CompletionException.class) + public CustomGraphQLException handle(CompletionException exception) { + if (exception.getCause() != null) { + return new CustomGraphQLException(exception.getCause()); + } else { + return new CustomGraphQLException(exception); + } + } +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/pagination/OffsetPageRequest.java b/modules/core/server-java/src/main/java/com/sysgears/core/pagination/OffsetPageRequest.java new file mode 100644 index 0000000000..07cb0dc697 --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/pagination/OffsetPageRequest.java @@ -0,0 +1,73 @@ +package com.sysgears.core.pagination; + +import lombok.Data; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@Data +public class OffsetPageRequest implements Pageable { + private final int offset; + private final int limit; + private final Sort sort; + + public OffsetPageRequest(int offset, int limit) { + this(offset, limit, Sort.unsorted()); + } + + public OffsetPageRequest(int offset, int limit, Sort sort) { + if (offset < 0) { + throw new IllegalArgumentException("Offset index must not be less than zero!"); + } + + if (limit < 1) { + throw new IllegalArgumentException("Limit must not be less than one!"); + } + this.offset = offset; + this.limit = limit; + this.sort = sort; + } + + @Override + public int getPageNumber() { + return offset / limit; + } + + @Override + public int getPageSize() { + return limit; + } + + @Override + public long getOffset() { + return offset; + } + + @Override + public Sort getSort() { + return sort; + } + + @Override + public Pageable next() { + return new OffsetPageRequest((int) getOffset() + getPageSize(), getPageSize(), getSort()); + } + + public OffsetPageRequest previous() { + return hasPrevious() ? new OffsetPageRequest((int) getOffset() - getPageSize(), getPageSize(), getSort()) : this; + } + + @Override + public Pageable previousOrFirst() { + return hasPrevious() ? previous() : first(); + } + + @Override + public Pageable first() { + return new OffsetPageRequest(0, getPageSize(), getSort()); + } + + @Override + public boolean hasPrevious() { + return offset > limit; + } +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/subscription/AbstractPubSubService.java b/modules/core/server-java/src/main/java/com/sysgears/core/subscription/AbstractPubSubService.java new file mode 100644 index 0000000000..289b2de436 --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/subscription/AbstractPubSubService.java @@ -0,0 +1,32 @@ +package com.sysgears.core.subscription; + +import io.reactivex.BackpressureStrategy; +import io.reactivex.Flowable; +import io.reactivex.Observable; +import io.reactivex.ObservableEmitter; +import io.reactivex.functions.Predicate; +import io.reactivex.observables.ConnectableObservable; + +public abstract class AbstractPubSubService implements Publisher, Subscriber { + private final Flowable publisher; + private ObservableEmitter emitter; + + public AbstractPubSubService() { + Observable observable = Observable.create(emitter -> this.emitter = emitter); + + ConnectableObservable connectableObservable = observable.share().publish(); + connectableObservable.connect(); + + publisher = connectableObservable.toFlowable(BackpressureStrategy.BUFFER); + } + + @Override + public Flowable subscribe(Predicate predicate) { + return this.publisher.filter(predicate); + } + + @Override + public void publish(T event) { + this.emitter.onNext(event); + } +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/subscription/Publisher.java b/modules/core/server-java/src/main/java/com/sysgears/core/subscription/Publisher.java new file mode 100644 index 0000000000..d8422899c1 --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/subscription/Publisher.java @@ -0,0 +1,5 @@ +package com.sysgears.core.subscription; + +public interface Publisher { + void publish(T event); +} diff --git a/modules/core/server-java/src/main/java/com/sysgears/core/subscription/Subscriber.java b/modules/core/server-java/src/main/java/com/sysgears/core/subscription/Subscriber.java new file mode 100644 index 0000000000..3d4e0e29d7 --- /dev/null +++ b/modules/core/server-java/src/main/java/com/sysgears/core/subscription/Subscriber.java @@ -0,0 +1,8 @@ +package com.sysgears.core.subscription; + +import io.reactivex.Flowable; +import io.reactivex.functions.Predicate; + +public interface Subscriber { + Flowable subscribe(Predicate predicate); +} diff --git a/modules/core/server-java/src/main/resources/schema.graphqls b/modules/core/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..3664fe44c5 --- /dev/null +++ b/modules/core/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,15 @@ +schema { + query: Query + mutation: Mutation + subscription: Subscription +} + +type Query { + +} +type Mutation { + +} +type Subscription { + +} \ No newline at end of file diff --git a/modules/counter/server-java/build.gradle b/modules/counter/server-java/build.gradle new file mode 100644 index 0000000000..378848af7c --- /dev/null +++ b/modules/counter/server-java/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':core') +} \ No newline at end of file diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/CounterDBInitializer.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/CounterDBInitializer.java new file mode 100644 index 0000000000..276fb10382 --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/CounterDBInitializer.java @@ -0,0 +1,25 @@ +package com.sysgears.counter; + +import com.sysgears.counter.model.Counter; +import com.sysgears.counter.repository.CounterRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CounterDBInitializer { + private final CounterRepository repository; + + @EventListener + public void onApplicationStartedEvent(ApplicationStartedEvent event) { + long count = repository.count(); + if (count == 0) { + repository.save(new Counter(1)); + log.debug("Counter initialized"); + } + } +} \ No newline at end of file diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/constant/CounterConstants.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/constant/CounterConstants.java new file mode 100644 index 0000000000..4893f46ae2 --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/constant/CounterConstants.java @@ -0,0 +1,9 @@ +package com.sysgears.counter.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class CounterConstants { + public static final int ID = 1; +} diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/model/Counter.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/model/Counter.java new file mode 100644 index 0000000000..9af016f228 --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/model/Counter.java @@ -0,0 +1,31 @@ +package com.sysgears.counter.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "COUNTER") +public class Counter { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + @Column(name = "ID") + private int id; + + @Column(name = "AMOUNT") + private int amount; + + public Counter(int amount) { + this.amount = amount; + } + + public Counter increaseAmount(int amount) { + this.amount += amount; + return this; + } +} \ No newline at end of file diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/repository/CounterRepository.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/repository/CounterRepository.java new file mode 100644 index 0000000000..c1b61f2398 --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/repository/CounterRepository.java @@ -0,0 +1,12 @@ +package com.sysgears.counter.repository; + +import com.sysgears.counter.model.Counter; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.scheduling.annotation.Async; + +import java.util.concurrent.CompletableFuture; + +public interface CounterRepository extends JpaRepository { + @Async + CompletableFuture findById(int id); +} \ No newline at end of file diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterMutationResolver.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterMutationResolver.java new file mode 100644 index 0000000000..305a0d919b --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterMutationResolver.java @@ -0,0 +1,33 @@ +package com.sysgears.counter.resolvers; + +import com.sysgears.core.subscription.Publisher; +import com.sysgears.counter.constant.CounterConstants; +import com.sysgears.counter.model.Counter; +import com.sysgears.counter.repository.CounterRepository; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CounterMutationResolver implements GraphQLMutationResolver { + private final CounterRepository repository; + private final Publisher publisher; + + public CompletableFuture addServerCounter(int amount) { + log.debug("Updating counter"); + return repository.findById(CounterConstants.ID) + .thenApply(counter -> { + counter.increaseAmount(amount); + repository.save(counter); + publisher.publish(counter); + return counter; + } + ); + } +} diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterQueryResolver.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterQueryResolver.java new file mode 100644 index 0000000000..e888c0f34b --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterQueryResolver.java @@ -0,0 +1,24 @@ +package com.sysgears.counter.resolvers; + +import com.sysgears.counter.constant.CounterConstants; +import com.sysgears.counter.model.Counter; +import com.sysgears.counter.repository.CounterRepository; +import graphql.kickstart.tools.GraphQLQueryResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CounterQueryResolver implements GraphQLQueryResolver { + private final CounterRepository repository; + + public CompletableFuture serverCounter() { + log.debug("Get counter"); + return repository.findById(CounterConstants.ID); + } +} diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterSubscriptionResolver.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterSubscriptionResolver.java new file mode 100644 index 0000000000..7ccbb0626f --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/resolvers/CounterSubscriptionResolver.java @@ -0,0 +1,21 @@ +package com.sysgears.counter.resolvers; + +import com.sysgears.core.subscription.Subscriber; +import com.sysgears.counter.model.Counter; +import graphql.kickstart.tools.GraphQLSubscriptionResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CounterSubscriptionResolver implements GraphQLSubscriptionResolver { + private final Subscriber subscriber; + + public Publisher counterUpdated() { + log.debug("Subscribing counter updates"); + return subscriber.subscribe(o -> true); + } +} diff --git a/modules/counter/server-java/src/main/java/com/sysgears/counter/subscription/CounterPubSubService.java b/modules/counter/server-java/src/main/java/com/sysgears/counter/subscription/CounterPubSubService.java new file mode 100644 index 0000000000..c4a84b08c3 --- /dev/null +++ b/modules/counter/server-java/src/main/java/com/sysgears/counter/subscription/CounterPubSubService.java @@ -0,0 +1,9 @@ +package com.sysgears.counter.subscription; + +import com.sysgears.core.subscription.AbstractPubSubService; +import com.sysgears.counter.model.Counter; +import org.springframework.stereotype.Component; + +@Component +public class CounterPubSubService extends AbstractPubSubService { +} diff --git a/modules/counter/server-java/src/main/resources/schema.graphqls b/modules/counter/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..d94a0026e2 --- /dev/null +++ b/modules/counter/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,14 @@ +extend type Query { + serverCounter: Counter! +} + +extend type Mutation { + addServerCounter(amount: Int!): Counter +} + +extend type Subscription { + counterUpdated: Counter +} +type Counter { + amount: Int! +} diff --git a/modules/counter/server-java/src/test/java/com/sysgears/counter/CounterMutationTest.java b/modules/counter/server-java/src/test/java/com/sysgears/counter/CounterMutationTest.java new file mode 100644 index 0000000000..55a6283a9b --- /dev/null +++ b/modules/counter/server-java/src/test/java/com/sysgears/counter/CounterMutationTest.java @@ -0,0 +1,36 @@ +package com.sysgears.counter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.counter.model.Counter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext +public class CounterMutationTest { + @Autowired + private GraphQLTestTemplate graphQLTestTemplate; + + @Test + void addServerCounter() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("amount", 2); + + GraphQLResponse response = graphQLTestTemplate.perform("mutation/add-server-counter.graphql", node); + + assertTrue(response.isOk()); + assertEquals(3, response.get("$.data.addServerCounter", Counter.class).getAmount()); + } +} diff --git a/modules/counter/server-java/src/test/java/com/sysgears/counter/CounterQueryTest.java b/modules/counter/server-java/src/test/java/com/sysgears/counter/CounterQueryTest.java new file mode 100644 index 0000000000..6f80b4bb8b --- /dev/null +++ b/modules/counter/server-java/src/test/java/com/sysgears/counter/CounterQueryTest.java @@ -0,0 +1,29 @@ +package com.sysgears.counter; + +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.counter.model.Counter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +public class CounterQueryTest { + @Autowired + private GraphQLTestTemplate graphQLTestTemplate; + + @Test + void serverCounter() throws IOException { + GraphQLResponse response = graphQLTestTemplate.postForResource("query/server-counter.graphql"); + + assertTrue(response.isOk()); + assertEquals(1, response.get("$.data.serverCounter", Counter.class).getAmount()); + } +} diff --git a/modules/counter/server-java/src/test/resources/mutation/add-server-counter.graphql b/modules/counter/server-java/src/test/resources/mutation/add-server-counter.graphql new file mode 100644 index 0000000000..eab577a3cb --- /dev/null +++ b/modules/counter/server-java/src/test/resources/mutation/add-server-counter.graphql @@ -0,0 +1,5 @@ +mutation AddServerCounter($amount: Int!) { + addServerCounter(amount: $amount) { + amount + } +} diff --git a/modules/counter/server-java/src/test/resources/query/server-counter.graphql b/modules/counter/server-java/src/test/resources/query/server-counter.graphql new file mode 100644 index 0000000000..881281c448 --- /dev/null +++ b/modules/counter/server-java/src/test/resources/query/server-counter.graphql @@ -0,0 +1,5 @@ +query ServerCounter { + serverCounter { + amount + } +} diff --git a/modules/i18n/server-java/build.gradle b/modules/i18n/server-java/build.gradle new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/i18n/server-java/src/main/java/com/sysgears/config/LocaleConfig.java b/modules/i18n/server-java/src/main/java/com/sysgears/config/LocaleConfig.java new file mode 100644 index 0000000000..c1a0300820 --- /dev/null +++ b/modules/i18n/server-java/src/main/java/com/sysgears/config/LocaleConfig.java @@ -0,0 +1,34 @@ +package com.sysgears.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.CookieLocaleResolver; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; + +import java.util.Locale; + +@Configuration +public class LocaleConfig implements WebMvcConfigurer { + + @Bean + public LocaleResolver localeResolver() { + CookieLocaleResolver localeResolver = new CookieLocaleResolver(); + localeResolver.setDefaultLocale(Locale.US); + return localeResolver; + } + + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); + interceptor.setParamName("lang"); + return interceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()).addPathPatterns("/**"); + } +} diff --git a/modules/i18n/server-java/src/main/java/com/sysgears/service/DefaultMessageResolver.java b/modules/i18n/server-java/src/main/java/com/sysgears/service/DefaultMessageResolver.java new file mode 100644 index 0000000000..1813d523c8 --- /dev/null +++ b/modules/i18n/server-java/src/main/java/com/sysgears/service/DefaultMessageResolver.java @@ -0,0 +1,17 @@ +package com.sysgears.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DefaultMessageResolver implements MessageResolver { + private final MessageSource messageSource; + + @Override + public String getLocalisedMessage(String code, Object... interpolationArguments) { + return messageSource.getMessage(code, interpolationArguments, LocaleContextHolder.getLocale()); + } +} diff --git a/modules/i18n/server-java/src/main/java/com/sysgears/service/MessageResolver.java b/modules/i18n/server-java/src/main/java/com/sysgears/service/MessageResolver.java new file mode 100644 index 0000000000..4f00ba6fd4 --- /dev/null +++ b/modules/i18n/server-java/src/main/java/com/sysgears/service/MessageResolver.java @@ -0,0 +1,6 @@ +package com.sysgears.service; + +public interface MessageResolver { + + String getLocalisedMessage(String code, Object... interpolationArguments); +} diff --git a/modules/mailer/server-java/build.gradle b/modules/mailer/server-java/build.gradle new file mode 100644 index 0000000000..358ccd4d4a --- /dev/null +++ b/modules/mailer/server-java/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation project(':core') + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' +} diff --git a/modules/mailer/server-java/src/main/java/com/sysgears/mailer/config/MailConfiguration.java b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/config/MailConfiguration.java new file mode 100644 index 0000000000..3786125b28 --- /dev/null +++ b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/config/MailConfiguration.java @@ -0,0 +1,17 @@ +package com.sysgears.mailer.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +public class MailConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "spring.mail", name = "username", havingValue = "EMAIL_USER") + public JavaMailSender javaMailSender() { + return new JavaMailSenderImpl(); + } +} diff --git a/modules/mailer/server-java/src/main/java/com/sysgears/mailer/config/ThymeleafConfiguration.java b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/config/ThymeleafConfiguration.java new file mode 100644 index 0000000000..23774b626e --- /dev/null +++ b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/config/ThymeleafConfiguration.java @@ -0,0 +1,34 @@ +package com.sysgears.mailer.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.thymeleaf.spring5.SpringTemplateEngine; +import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver; +import org.thymeleaf.spring5.view.ThymeleafViewResolver; +import org.thymeleaf.templatemode.TemplateMode; + +@Configuration +public class ThymeleafConfiguration { + @Bean + public SpringTemplateEngine templateEngine() { + SpringTemplateEngine templateEngine = new SpringTemplateEngine(); + templateEngine.setTemplateResolver(thymeleafTemplateResolver()); + return templateEngine; + } + + @Bean + public SpringResourceTemplateResolver thymeleafTemplateResolver() { + SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver(); + templateResolver.setPrefix("classpath:/templates/"); + templateResolver.setSuffix(".html"); + templateResolver.setTemplateMode(TemplateMode.HTML); + return templateResolver; + } + + @Bean + public ThymeleafViewResolver thymeleafViewResolver() { + ThymeleafViewResolver viewResolver = new ThymeleafViewResolver(); + viewResolver.setTemplateEngine(templateEngine()); + return viewResolver; + } +} diff --git a/modules/mailer/server-java/src/main/java/com/sysgears/mailer/service/DefaultEmailService.java b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/service/DefaultEmailService.java new file mode 100644 index 0000000000..cfe9c8ad0b --- /dev/null +++ b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/service/DefaultEmailService.java @@ -0,0 +1,104 @@ +package com.sysgears.mailer.service; + +import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring5.SpringTemplateEngine; + +import javax.mail.internet.MimeMessage; +import java.util.Map; +import java.util.Optional; + +@Service +public class DefaultEmailService implements EmailService { + private final JavaMailSender mailSender; + private final SpringTemplateEngine templateEngine; + private final String baseUrl; + private final String profileRedirectedUrl; + private final String emailSender; + + + public DefaultEmailService(JavaMailSender mailSender, + SpringTemplateEngine templateEngine, + @Value("${app.server.baseUrl}") String baseUrl, + @Value("${app.redirect.profile}") String profileRedirectedUrl, + @Value("${spring.mail.username}") String emailSender) { + this.mailSender = mailSender; + this.templateEngine = templateEngine; + this.baseUrl = baseUrl; + this.profileRedirectedUrl = profileRedirectedUrl; + this.emailSender = emailSender; + } + + public void sendRegistrationConfirmEmail(String name, String email, String confirmationPath) { + Context context = new Context(); + String confirmationLink = UriComponentsBuilder.newInstance() + .scheme(baseUrl.contains("localhost") ? "http" : "https") + .host(baseUrl) + .path(confirmationPath) + .build() + .toString(); + context.setVariables(Map.of("name", name, "followLink", confirmationLink)); + + String emailBody = templateEngine.process("confirm-registration", context); + + mailSender.send(createMessage(Optional.empty(), email, "Confirm Email", emailBody)); + } + + public void sendResetPasswordEmail(String email, String resetPasswordPath) { + Context context = new Context(); + String confirmationLink = UriComponentsBuilder.newInstance() + .scheme(baseUrl.contains("localhost") ? "http" : "https") + .host(baseUrl) + .path(resetPasswordPath) + .build() + .toString(); + context.setVariables(Map.of("followLink", confirmationLink)); + + String emailBody = templateEngine.process("reset-password", context); + + mailSender.send(createMessage(Optional.empty(), email, "Reset Password", emailBody)); + } + + public void sendPasswordUpdatedEmail(String email) { + Context context = new Context(); + context.setVariables(Map.of("followLink", profileRedirectedUrl)); + + String emailBody = templateEngine.process("password-updated", context); + + mailSender.send(createMessage(Optional.empty(), email, "Your Password Has Been Updated", emailBody)); + } + + @SneakyThrows + public void sendContactUsEmail(String name, String email, String content) { + Context context = new Context(); + context.setVariables(Map.of("name", name, "content", content)); + + String emailBody = templateEngine.process("contact-us", context); + + mailSender.send(createMessage(Optional.of(email), emailSender, "New email through contact us page", emailBody)); + } + + @SneakyThrows + private MimeMessage createMessage(Optional from, String to, String subject, String text) { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper messageHelper = new MimeMessageHelper(message, false, "UTF-8"); + + // it will be ignored for gmail + // https://stackoverflow.com/a/27420030 + if (from.isPresent()) { + messageHelper.setFrom(from.get()); + } else { + messageHelper.setFrom(emailSender); + } + + messageHelper.setTo(to); + messageHelper.setSubject(subject); + messageHelper.setText(text, true); + return message; + } +} diff --git a/modules/mailer/server-java/src/main/java/com/sysgears/mailer/service/EmailService.java b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/service/EmailService.java new file mode 100644 index 0000000000..fc3bfe21c7 --- /dev/null +++ b/modules/mailer/server-java/src/main/java/com/sysgears/mailer/service/EmailService.java @@ -0,0 +1,12 @@ +package com.sysgears.mailer.service; + +public interface EmailService { + + void sendRegistrationConfirmEmail(String name, String email, String confirmationLink); + + void sendResetPasswordEmail(String email, String resetPasswordPath); + + void sendPasswordUpdatedEmail(String email); + + void sendContactUsEmail(String name, String email, String content); +} diff --git a/modules/post/server-java/build.gradle b/modules/post/server-java/build.gradle new file mode 100644 index 0000000000..e4dbb7fe36 --- /dev/null +++ b/modules/post/server-java/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':core') +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/PostDBInitializer.java b/modules/post/server-java/src/main/java/com/sysgears/post/PostDBInitializer.java new file mode 100644 index 0000000000..a755b89a9a --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/PostDBInitializer.java @@ -0,0 +1,39 @@ +package com.sysgears.post; + +import com.sysgears.post.model.Comment; +import com.sysgears.post.model.Post; +import com.sysgears.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostDBInitializer { + private final PostRepository repository; + + @EventListener + public void onApplicationStartedEvent(ApplicationStartedEvent event) { + long count = repository.count(); + if (count == 0) { + List posts = IntStream.rangeClosed(1, 20).mapToObj(i -> { + Post post = new Post(); + post.setTitle("Post title " + i); + post.setContent("Some content " + i); + Comment comment = new Comment(); + comment.setContent("123"); + post.setComments(Set.of(comment)); + return post; + }).collect(Collectors.toList()); + repository.saveAll(posts); + } + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/CommentPayload.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/CommentPayload.java new file mode 100644 index 0000000000..c6857315ed --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/CommentPayload.java @@ -0,0 +1,16 @@ +package com.sysgears.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentPayload { + @NonNull + private Integer id; + @NonNull + private String content; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostEdges.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostEdges.java new file mode 100644 index 0000000000..d350b6c4b5 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostEdges.java @@ -0,0 +1,15 @@ +package com.sysgears.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostEdges { + private PostPayload node; + private Integer cursor; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostPageInfo.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostPageInfo.java new file mode 100644 index 0000000000..693c9fd659 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostPageInfo.java @@ -0,0 +1,15 @@ +package com.sysgears.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostPageInfo { + private Integer endCursor; + private Boolean hasNextPage; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostPayload.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostPayload.java new file mode 100644 index 0000000000..508a5cece2 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/PostPayload.java @@ -0,0 +1,28 @@ +package com.sysgears.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PostPayload { + @NonNull + private Integer id; + @NonNull + private String title; + @NonNull + private String content; + + private final List comments = new ArrayList<>(); + + public void addComments(Collection comments) { + this.comments.addAll(comments); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/Posts.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/Posts.java new file mode 100644 index 0000000000..0356309511 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/Posts.java @@ -0,0 +1,18 @@ +package com.sysgears.post.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Posts { + private Long totalCount; + private List edges; + private PostPageInfo pageInfo; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/AddCommentInput.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/AddCommentInput.java new file mode 100644 index 0000000000..35b5a50fe6 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/AddCommentInput.java @@ -0,0 +1,16 @@ +package com.sysgears.post.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AddCommentInput { + @NonNull + private Integer postId; + @NonNull + private String content; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/AddPostInput.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/AddPostInput.java new file mode 100644 index 0000000000..528288e83d --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/AddPostInput.java @@ -0,0 +1,16 @@ +package com.sysgears.post.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AddPostInput { + @NonNull + private String title; + @NonNull + private String content; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/DeleteCommentInput.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/DeleteCommentInput.java new file mode 100644 index 0000000000..4e0c18ace3 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/DeleteCommentInput.java @@ -0,0 +1,16 @@ +package com.sysgears.post.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeleteCommentInput { + @NonNull + private Integer id; + @NonNull + private Integer postId; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/EditCommentInput.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/EditCommentInput.java new file mode 100644 index 0000000000..da685504b4 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/EditCommentInput.java @@ -0,0 +1,18 @@ +package com.sysgears.post.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EditCommentInput { + @NonNull + private Integer id; + @NonNull + private Integer postId; + @NonNull + private String content; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/EditPostInput.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/EditPostInput.java new file mode 100644 index 0000000000..d8ec1e4c6d --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/input/EditPostInput.java @@ -0,0 +1,18 @@ +package com.sysgears.post.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EditPostInput { + @NonNull + private Integer id; + @NonNull + private String title; + @NonNull + private String content; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/subscription/UpdateCommentPayload.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/subscription/UpdateCommentPayload.java new file mode 100644 index 0000000000..9f24cfd610 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/subscription/UpdateCommentPayload.java @@ -0,0 +1,16 @@ +package com.sysgears.post.dto.subscription; + +import com.sysgears.post.dto.CommentPayload; +import lombok.Data; +import org.springframework.lang.NonNull; + +@Data +public class UpdateCommentPayload { + private final Integer id; + @NonNull + private final Integer postId; + @NonNull + private final String mutation; + private final CommentPayload node; + +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/dto/subscription/UpdatePostPayload.java b/modules/post/server-java/src/main/java/com/sysgears/post/dto/subscription/UpdatePostPayload.java new file mode 100644 index 0000000000..bb42876f93 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/dto/subscription/UpdatePostPayload.java @@ -0,0 +1,15 @@ +package com.sysgears.post.dto.subscription; + +import com.sysgears.post.dto.PostPayload; +import lombok.Data; +import org.springframework.lang.NonNull; + +@Data +public class UpdatePostPayload { + @NonNull + private final Integer id; + @NonNull + private final String mutation; + private final PostPayload node; + +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/exception/CommentNotFoundException.java b/modules/post/server-java/src/main/java/com/sysgears/post/exception/CommentNotFoundException.java new file mode 100644 index 0000000000..9e57f5db6c --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/exception/CommentNotFoundException.java @@ -0,0 +1,8 @@ +package com.sysgears.post.exception; + +public class CommentNotFoundException extends RuntimeException { + + public CommentNotFoundException(Integer id) { + super(String.format("Comment with id %d not found", id)); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/exception/PostNotFoundException.java b/modules/post/server-java/src/main/java/com/sysgears/post/exception/PostNotFoundException.java new file mode 100644 index 0000000000..77303fb210 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/exception/PostNotFoundException.java @@ -0,0 +1,8 @@ +package com.sysgears.post.exception; + +public class PostNotFoundException extends RuntimeException { + + public PostNotFoundException(Integer id) { + super(String.format("Post with id %d not found", id)); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/model/Comment.java b/modules/post/server-java/src/main/java/com/sysgears/post/model/Comment.java new file mode 100644 index 0000000000..2ee62adc03 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/model/Comment.java @@ -0,0 +1,29 @@ +package com.sysgears.post.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "СOMMENT") +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "CONTENT", nullable = false) + private String content; + + @ManyToOne + private Post post; + + public Comment(String content) { + this.content = content; + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/model/Post.java b/modules/post/server-java/src/main/java/com/sysgears/post/model/Post.java new file mode 100644 index 0000000000..15ad9b2b38 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/model/Post.java @@ -0,0 +1,49 @@ +package com.sysgears.post.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "POST") +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "TITLE", nullable = false) + private String title; + + @Column(name = "CONTENT", nullable = false) + private String content; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(FetchMode.JOIN) + @JoinTable(name = "POST_COMMENT", + joinColumns = { @JoinColumn(name = "POST_ID", referencedColumnName = "id") }, + inverseJoinColumns = { @JoinColumn(name = "COMMENT_ID", referencedColumnName = "id") }) + private Set comments = new HashSet<>(); + + public Post(String title, String content) { + this.title = title; + this.content = content; + } + + public void addComment(Comment comment) { + comments.add(comment); + } + + public void removeComment(Comment comment) { + comments.remove(comment); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/repository/CommentRepository.java b/modules/post/server-java/src/main/java/com/sysgears/post/repository/CommentRepository.java new file mode 100644 index 0000000000..d525552efc --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package com.sysgears.post.repository; + +import com.sysgears.post.model.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/repository/PostRepository.java b/modules/post/server-java/src/main/java/com/sysgears/post/repository/PostRepository.java new file mode 100644 index 0000000000..be3aea9005 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/repository/PostRepository.java @@ -0,0 +1,13 @@ +package com.sysgears.post.repository; + +import com.sysgears.post.model.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.scheduling.annotation.Async; + +import java.util.concurrent.CompletableFuture; + +public interface PostRepository extends JpaRepository { + + @Async + CompletableFuture getById(Integer id); +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/CommentMutationResolver.java b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/CommentMutationResolver.java new file mode 100644 index 0000000000..6ba6e29fbe --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/CommentMutationResolver.java @@ -0,0 +1,56 @@ +package com.sysgears.post.resolvers; + +import com.sysgears.core.subscription.Publisher; +import com.sysgears.post.dto.CommentPayload; +import com.sysgears.post.dto.input.AddCommentInput; +import com.sysgears.post.dto.input.DeleteCommentInput; +import com.sysgears.post.dto.input.EditCommentInput; +import com.sysgears.post.model.Comment; +import com.sysgears.post.service.PostService; +import com.sysgears.post.subscription.CommentUpdatedEvent; +import com.sysgears.post.subscription.Mutation; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class CommentMutationResolver implements GraphQLMutationResolver { + private final PostService postService; + private final Publisher commentPublisher; + + public CompletableFuture addComment(AddCommentInput input) { + return CompletableFuture.supplyAsync(() -> { + Comment comment = postService.addComment(input.getPostId(), input.getContent()); + + CommentPayload commentPayload = new CommentPayload(comment.getId(), comment.getContent()); + commentPublisher.publish(new CommentUpdatedEvent(Mutation.CREATED, input.getPostId(), commentPayload)); + + return commentPayload; + }); + } + + public CompletableFuture deleteComment(DeleteCommentInput input) { + return CompletableFuture.supplyAsync(() -> { + Comment deletedComment = postService.deleteComment(input.getPostId(), input.getId()); + + CommentPayload commentPayload = new CommentPayload(deletedComment.getId(), deletedComment.getContent()); + commentPublisher.publish(new CommentUpdatedEvent(Mutation.DELETED, input.getPostId(), commentPayload)); + + return commentPayload; + }); + } + + public CompletableFuture editComment(EditCommentInput input) { + return CompletableFuture.supplyAsync(() -> { + Comment editedComment = postService.editComment(input.getPostId(), input.getId(), input.getContent()); + + CommentPayload commentPayload = new CommentPayload(editedComment.getId(), editedComment.getContent()); + commentPublisher.publish(new CommentUpdatedEvent(Mutation.UPDATED, input.getPostId(), commentPayload)); + + return commentPayload; + }); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/CommentSubscriptionResolver.java b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/CommentSubscriptionResolver.java new file mode 100644 index 0000000000..48f8ce19a7 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/CommentSubscriptionResolver.java @@ -0,0 +1,30 @@ +package com.sysgears.post.resolvers; + +import com.sysgears.core.subscription.Subscriber; +import com.sysgears.post.dto.subscription.UpdateCommentPayload; +import com.sysgears.post.subscription.CommentUpdatedEvent; +import graphql.kickstart.tools.GraphQLSubscriptionResolver; +import lombok.RequiredArgsConstructor; +import org.reactivestreams.Publisher; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class CommentSubscriptionResolver implements GraphQLSubscriptionResolver { + private final Subscriber commentSubscriber; + + public CompletableFuture> commentUpdated(Integer postId) { + return CompletableFuture.supplyAsync(() -> commentSubscriber.subscribe(e -> e.getPostId().equals(postId)) + .map(event -> + new UpdateCommentPayload( + event.getComment().getId(), + event.getPostId(), + event.getMutation().name(), + event.getComment() + ) + ) + ); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostMutationResolver.java b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostMutationResolver.java new file mode 100644 index 0000000000..e7aca8c48a --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostMutationResolver.java @@ -0,0 +1,60 @@ +package com.sysgears.post.resolvers; + +import com.sysgears.core.subscription.Publisher; +import com.sysgears.post.dto.PostPayload; +import com.sysgears.post.dto.input.AddPostInput; +import com.sysgears.post.dto.input.EditPostInput; +import com.sysgears.post.model.Post; +import com.sysgears.post.service.PostService; +import com.sysgears.post.subscription.Mutation; +import com.sysgears.post.subscription.PostUpdatedEvent; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class PostMutationResolver implements GraphQLMutationResolver { + private final PostService postService; + private final Publisher postPublisher; + + public CompletableFuture addPost(AddPostInput input) { + return CompletableFuture.supplyAsync(() -> { + Post post = new Post(input.getTitle(), input.getContent()); + Post created = postService.create(post); + + PostPayload postPayload = new PostPayload(created.getId(), created.getTitle(), created.getContent()); + postPublisher.publish(new PostUpdatedEvent(Mutation.CREATED, postPayload)); + + return postPayload; + }); + } + + public CompletableFuture deletePost(Integer id) { + return CompletableFuture.supplyAsync(() -> { + Post deletedPost = postService.deleteById(id); + + PostPayload postPayload = new PostPayload(deletedPost.getId(), deletedPost.getTitle(), deletedPost.getContent()); + postPublisher.publish(new PostUpdatedEvent(Mutation.DELETED, postPayload)); + + return postPayload; + }); + } + + public CompletableFuture editPost(EditPostInput input) { + return CompletableFuture.supplyAsync(() -> { + Post post = postService.findById(input.getId()); + post.setTitle(input.getTitle()); + post.setContent(input.getContent()); + + Post updated = postService.update(post); + + PostPayload postPayload = new PostPayload(updated.getId(), updated.getTitle(), updated.getContent()); + postPublisher.publish(new PostUpdatedEvent(Mutation.UPDATED, postPayload)); + + return postPayload; + }); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostQueryResolver.java b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostQueryResolver.java new file mode 100644 index 0000000000..86c1d7ab46 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostQueryResolver.java @@ -0,0 +1,32 @@ +package com.sysgears.post.resolvers; + +import com.sysgears.post.dto.PostPayload; +import com.sysgears.post.dto.Posts; +import com.sysgears.core.pagination.OffsetPageRequest; +import com.sysgears.post.service.PostService; +import graphql.kickstart.tools.GraphQLQueryResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class PostQueryResolver implements GraphQLQueryResolver { + private final PostService postService; + + public CompletableFuture posts(Optional limit, Optional after) { + return CompletableFuture.supplyAsync(() -> { + int offset = after.orElse(0); + int size = limit.orElse(10); + Pageable pageRequest = new OffsetPageRequest(offset, size); + return postService.findAll(pageRequest); + }); + } + + public CompletableFuture post(Integer id) { + return postService.getById(id); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostSubscriptionResolver.java b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostSubscriptionResolver.java new file mode 100644 index 0000000000..e3e4e5592c --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/resolvers/PostSubscriptionResolver.java @@ -0,0 +1,31 @@ +package com.sysgears.post.resolvers; + +import com.sysgears.core.subscription.Subscriber; +import com.sysgears.post.dto.subscription.UpdatePostPayload; +import com.sysgears.post.subscription.Mutation; +import com.sysgears.post.subscription.PostUpdatedEvent; +import graphql.kickstart.tools.GraphQLSubscriptionResolver; +import lombok.RequiredArgsConstructor; +import org.reactivestreams.Publisher; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class PostSubscriptionResolver implements GraphQLSubscriptionResolver { + private final Subscriber postSubscriber; + + public CompletableFuture> postUpdated(Integer id) { + return CompletableFuture.supplyAsync(() -> postSubscriber.subscribe(e -> + e.getPost().getId().equals(id) && (e.getMutation() == Mutation.UPDATED || e.getMutation() == Mutation.DELETED)) + .map(event -> new UpdatePostPayload(event.getPost().getId(), event.getMutation().name(), event.getPost())) + ); + } + + public CompletableFuture> postsUpdated(Integer endCursor) { + return CompletableFuture.supplyAsync(() -> postSubscriber.subscribe(e -> e.getPost().getId() <= endCursor) + .map(event -> new UpdatePostPayload(event.getPost().getId(), event.getMutation().name(), event.getPost())) + ); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/service/PostService.java b/modules/post/server-java/src/main/java/com/sysgears/post/service/PostService.java new file mode 100644 index 0000000000..0e9b152e37 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/service/PostService.java @@ -0,0 +1,128 @@ +package com.sysgears.post.service; + +import com.sysgears.post.dto.*; +import com.sysgears.post.exception.CommentNotFoundException; +import com.sysgears.post.exception.PostNotFoundException; +import com.sysgears.post.model.Comment; +import com.sysgears.post.model.Post; +import com.sysgears.post.repository.CommentRepository; +import com.sysgears.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PostService { + private final PostRepository repository; + private final CommentRepository commentRepository; + + @Transactional(readOnly = true) + public Posts findAll(Pageable pageable) { + Page posts = repository.findAll(pageable); + + List postEdgesList = new ArrayList<>(); + + for (int i = 0; i < posts.getContent().size(); i++) { + Post post = posts.getContent().get(i); + postEdgesList.add( + PostEdges.builder() + .cursor((int) (pageable.getOffset() + i)) + .node(new PostPayload(post.getId(), post.getTitle(), post.getContent())) + .build() + ); + } + + return Posts.builder() + .edges(postEdgesList) + .pageInfo( + PostPageInfo.builder() + .endCursor((int) (pageable.getOffset() + posts.getSize() - 1)) + .hasNextPage(!posts.isLast()) + .build() + ) + .totalCount(posts.getTotalElements()) + .build(); + } + + @Transactional(readOnly = true) + public CompletableFuture getById(Integer id) { + return repository.getById(id).thenApply(post -> { + if (post == null) throw new PostNotFoundException(id); + + PostPayload postPayload = new PostPayload(id, post.getTitle(), post.getContent()); + postPayload.addComments(convertComments(post.getComments())); + return postPayload; + }); + } + + @Transactional(readOnly = true) + public Post findById(Integer id) { + return repository.findById(id).orElseThrow(() -> new PostNotFoundException(id)); + } + + @Transactional + public Post create(Post post) { + return repository.save(post); + } + + @Transactional + public Post deleteById(Integer id) { + Post post = findById(id); + + repository.delete(post); + return post; + } + + @Transactional + public Post update(Post post) { + return repository.save(post); + } + + @Transactional + public Comment addComment(Integer postId, String commentContent) { + Post post = findById(postId); + + Comment comment = commentRepository.save(new Comment(commentContent)); + post.addComment(comment); + + update(post); + + return comment; + } + + @Transactional + public Comment deleteComment(Integer postId, Integer commentId) { + Post post = findById(postId); + + Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new CommentNotFoundException(commentId)); + post.removeComment(comment); + + update(post); + + return comment; + } + + @Transactional + public Comment editComment(Integer postId, Integer commentId, String content) { + Post post = findById(postId); + Comment comment = commentRepository.findById(commentId).orElseThrow(() -> new CommentNotFoundException(commentId)); + comment.setContent(content); + + update(post); + + return comment; + } + + private Set convertComments(Collection comments) { + return comments.stream() + .map(comment -> new CommentPayload(comment.getId(), comment.getContent())) + .collect(Collectors.toSet()); + } +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/subscription/CommentPubSubService.java b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/CommentPubSubService.java new file mode 100644 index 0000000000..0f88f96467 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/CommentPubSubService.java @@ -0,0 +1,8 @@ +package com.sysgears.post.subscription; + +import com.sysgears.core.subscription.AbstractPubSubService; +import org.springframework.stereotype.Component; + +@Component +public class CommentPubSubService extends AbstractPubSubService { +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/subscription/CommentUpdatedEvent.java b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/CommentUpdatedEvent.java new file mode 100644 index 0000000000..828db7f063 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/CommentUpdatedEvent.java @@ -0,0 +1,11 @@ +package com.sysgears.post.subscription; + +import com.sysgears.post.dto.CommentPayload; +import lombok.Data; + +@Data +public class CommentUpdatedEvent { + private final Mutation mutation; + private final Integer postId; + private final CommentPayload comment; +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/subscription/Mutation.java b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/Mutation.java new file mode 100644 index 0000000000..82c186c4db --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/Mutation.java @@ -0,0 +1,5 @@ +package com.sysgears.post.subscription; + +public enum Mutation { + DELETED, UPDATED, CREATED +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/subscription/PostPubSubService.java b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/PostPubSubService.java new file mode 100644 index 0000000000..e4c0cb3655 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/PostPubSubService.java @@ -0,0 +1,8 @@ +package com.sysgears.post.subscription; + +import com.sysgears.core.subscription.AbstractPubSubService; +import org.springframework.stereotype.Component; + +@Component +public class PostPubSubService extends AbstractPubSubService { +} diff --git a/modules/post/server-java/src/main/java/com/sysgears/post/subscription/PostUpdatedEvent.java b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/PostUpdatedEvent.java new file mode 100644 index 0000000000..65619a3705 --- /dev/null +++ b/modules/post/server-java/src/main/java/com/sysgears/post/subscription/PostUpdatedEvent.java @@ -0,0 +1,10 @@ +package com.sysgears.post.subscription; + +import com.sysgears.post.dto.PostPayload; +import lombok.Data; + +@Data +public class PostUpdatedEvent { + private final Mutation mutation; + private final PostPayload post; +} diff --git a/modules/post/server-java/src/main/resources/schema.graphqls b/modules/post/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..0c1d7b31d9 --- /dev/null +++ b/modules/post/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,113 @@ +# Post +type Post { + id: Int! + title: String! + content: String! + comments: [Comment] +} + +# Comment +type Comment { + id: Int! + content: String! +} + +# Edges for Posts +type PostEdges { + node: Post + cursor: Int +} + +# PageInfo for Posts +type PostPageInfo { + endCursor: Int + hasNextPage: Boolean +} + +# Posts relay-style pagination query +type Posts { + totalCount: Int + edges: [PostEdges] + pageInfo: PostPageInfo +} + +extend type Query { + # Posts pagination query + posts(limit: Int, after: Int): Posts + # Post + post(id: Int!): Post +} + +extend type Mutation { + # Create new post + addPost(input: AddPostInput!): Post + # Delete a post + deletePost(id: Int!): Post + # Edit a post + editPost(input: EditPostInput!): Post + # Add comment to post + addComment(input: AddCommentInput!): Comment + # Delete a comment + deleteComment(input: DeleteCommentInput!): Comment + # Edit a comment + editComment(input: EditCommentInput!): Comment +} + +# Input for addPost Mutation +input AddPostInput { + title: String! + content: String! +} + +# Input for editPost Mutation +input EditPostInput { + id: Int! + title: String! + content: String! +} + +# Input for addComment Mutation +input AddCommentInput { + content: String! + # Needed for commentUpdated Subscription filter + postId: Int! +} + +# Input for editComment Mutation +input DeleteCommentInput { + id: Int! + # Needed for commentUpdated Subscription filter + postId: Int! +} + +# Input for deleteComment Mutation +input EditCommentInput { + id: Int! + content: String! + # Needed for commentUpdated Subscription filter + postId: Int! +} + +extend type Subscription { + # Subscription for when editing a post + postUpdated(id: Int!): UpdatePostPayload + # Subscription for post list + postsUpdated(endCursor: Int!): UpdatePostPayload + # Subscription for comments + commentUpdated(postId: Int!): UpdateCommentPayload +} + +# Payload for postsUpdated Subscription +type UpdatePostPayload { + mutation: String! + id: Int! + node: Post +} + +# Payload for commentUpdated Subscription +type UpdateCommentPayload { + mutation: String! + id: Int + postId: Int! + node: Comment +} diff --git a/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/CommentMutationResolverTest.java b/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/CommentMutationResolverTest.java new file mode 100644 index 0000000000..5e32791c73 --- /dev/null +++ b/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/CommentMutationResolverTest.java @@ -0,0 +1,153 @@ +package com.sysgears.post.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.post.dto.CommentPayload; +import com.sysgears.post.model.Comment; +import com.sysgears.post.model.Post; +import com.sysgears.post.repository.PostRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class CommentMutationResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private PostRepository postRepository; + + @Test + void addComment() throws IOException { + Post post = postRepository.saveAndFlush(new Post("Title", "Content")); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String content = "Comment content"; + node.put("postId", post.getId()); + node.put("content", content); + + input.set("input", node); + GraphQLResponse response = template.perform("comment/add-comment.graphql", input); + + assertTrue(response.isOk()); + CommentPayload commentPayload = response.get("$.data.addComment", CommentPayload.class); + assertNotNull(commentPayload.getId()); + assertEquals(content, commentPayload.getContent()); + } + + @Test + void addComment_for_non_existent_post() throws IOException { + int invalidPostId = 111111; + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String content = "Comment content"; + node.put("postId", invalidPostId); + node.put("content", content); + + input.set("input", node); + GraphQLResponse response = template.perform("comment/add-comment.graphql", input); + + assertTrue(response.isOk()); + assertEquals(String.format("Post with id %d not found", invalidPostId), response.get("$.errors[0].message")); + } + + @Test + void deleteComment() throws IOException { + Post post = new Post("Title", "Content"); + Comment comment = new Comment("comment..."); + post.addComment(comment); + Post savedPost = postRepository.saveAndFlush(post); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", comment.getId()); + node.put("postId", savedPost.getId()); + + input.set("input", node); + GraphQLResponse response = template.perform("comment/delete-comment.graphql", input); + + assertTrue(response.isOk()); + CommentPayload commentPayload = response.get("$.data.deleteComment", CommentPayload.class); + assertEquals(comment.getId(), commentPayload.getId()); + assertEquals(comment.getContent(), commentPayload.getContent()); + } + + @Test + void deleteComment_with_invalid_id() throws IOException { + Post post = postRepository.saveAndFlush(new Post("Title", "Content")); + int invalidCommentId = 4123111; + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", invalidCommentId); + node.put("postId", post.getId()); + + input.set("input", node); + GraphQLResponse response = template.perform("comment/delete-comment.graphql", input); + + assertTrue(response.isOk()); + assertEquals(String.format("Comment with id %d not found", invalidCommentId), response.get("$.errors[0].message")); + } + + @Test + void editComment() throws IOException { + Post post = new Post("Post title", "some post content..."); + Comment comment = new Comment("comment..."); + post.addComment(comment); + Post savedPost = postRepository.saveAndFlush(post); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String content = "new comment content"; + node.put("id", comment.getId()); + node.put("postId", savedPost.getId()); + node.put("content", content); + + input.set("input", node); + GraphQLResponse response = template.perform("comment/edit-comment.graphql", input); + + assertTrue(response.isOk()); + CommentPayload commentPayload = response.get("$.data.editComment", CommentPayload.class); + assertEquals(comment.getId(), commentPayload.getId()); + assertEquals(content, commentPayload.getContent()); + } + + @Test + void editComment_with_invalid_id() throws IOException { + Post post = postRepository.saveAndFlush(new Post("Post Title", "Post Content")); + int invalidCommentId = 662525; + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String content = "Comment content"; + node.put("id", invalidCommentId); + node.put("postId", post.getId()); + node.put("content", content); + + input.set("input", node); + GraphQLResponse response = template.perform("comment/edit-comment.graphql", input); + + assertTrue(response.isOk()); + assertEquals(String.format("Comment with id %d not found", invalidCommentId), response.get("$.errors[0].message")); + } +} diff --git a/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/PostMutationResolverTest.java b/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/PostMutationResolverTest.java new file mode 100644 index 0000000000..53553f565c --- /dev/null +++ b/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/PostMutationResolverTest.java @@ -0,0 +1,121 @@ +package com.sysgears.post.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.post.dto.PostPayload; +import com.sysgears.post.model.Post; +import com.sysgears.post.repository.PostRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class PostMutationResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private PostRepository postRepository; + + @Test + void addPost() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String title = "Post title"; + String content = "Post content"; + node.put("title", title); + node.put("content", content); + + input.set("input", node); + GraphQLResponse response = template.perform("post/add-post.graphql", input); + + assertTrue(response.isOk()); + PostPayload postPayload = response.get("$.data.addPost", PostPayload.class); + assertNotNull(postPayload.getId()); + assertEquals(title, postPayload.getTitle()); + assertEquals(content, postPayload.getContent()); + assertTrue(postPayload.getComments().isEmpty()); + } + + @Test + void deletePost() throws IOException { + Post post = postRepository.saveAndFlush(new Post("Title", "Content")); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", post.getId()); + + GraphQLResponse response = template.perform("post/delete-post.graphql", node); + + assertTrue(response.isOk()); + PostPayload postPayload = response.get("$.data.deletePost", PostPayload.class); + assertEquals(post.getId(), postPayload.getId()); + assertEquals(post.getTitle(), postPayload.getTitle()); + assertEquals(post.getContent(), postPayload.getContent()); + } + + @Test + void deletePost_with_invalid_id() throws IOException { + int invalidPostId = 23456; + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", invalidPostId); + + GraphQLResponse response = template.perform("post/delete-post.graphql", node); + + assertTrue(response.isOk()); + assertEquals(String.format("Post with id %d not found", invalidPostId), response.get("$.errors[0].message")); + } + + @Test + void editPost() throws IOException { + Post post = postRepository.saveAndFlush(new Post("Title", "Content")); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String title = "Post title"; + String content = "Post content"; + node.put("id", post.getId()); + node.put("title", title); + node.put("content", content); + + input.set("input", node); + GraphQLResponse response = template.perform("post/edit-post.graphql", input); + + assertTrue(response.isOk()); + PostPayload postPayload = response.get("$.data.editPost", PostPayload.class); + assertEquals(post.getId(), postPayload.getId()); + assertEquals(title, postPayload.getTitle()); + assertEquals(content, postPayload.getContent()); + assertTrue(postPayload.getComments().isEmpty()); + } + + @Test + void editPost_with_invalid_id() throws IOException { + int invalidPostId = 99999; + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", invalidPostId); + node.put("title", "New title"); + node.put("content", "New content"); + + input.set("input", node); + GraphQLResponse response = template.perform("post/edit-post.graphql", input); + + assertTrue(response.isOk()); + assertEquals(String.format("Post with id %d not found", invalidPostId), response.get("$.errors[0].message")); + } +} diff --git a/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/PostQueryResolverTest.java b/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/PostQueryResolverTest.java new file mode 100644 index 0000000000..ed00535c03 --- /dev/null +++ b/modules/post/server-java/src/test/java/com/sysgears/post/resolvers/PostQueryResolverTest.java @@ -0,0 +1,124 @@ +package com.sysgears.post.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.post.dto.CommentPayload; +import com.sysgears.post.dto.PostEdges; +import com.sysgears.post.dto.PostPayload; +import com.sysgears.post.dto.Posts; +import com.sysgears.post.model.Comment; +import com.sysgears.post.model.Post; +import com.sysgears.post.repository.PostRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class PostQueryResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private PostRepository postRepository; + private List postList; + @BeforeEach + void init() { + postList = preparePosts(); + } + + @Test + void posts_with_default_limit() throws IOException { + GraphQLResponse response = template.postForResource("post/get-posts.graphql"); + + assertTrue(response.isOk()); + Posts posts = response.get("$.data.posts", Posts.class); + assertEquals(postList.size(), posts.getTotalCount()); + assertEquals(postList.size() - 1, posts.getPageInfo().getEndCursor()); + assertFalse(posts.getPageInfo().getHasNextPage()); + + List expected = postList.stream().map(post -> new PostPayload(post.getId(), post.getTitle(), post.getContent())).collect(Collectors.toList()); + List actual = posts.getEdges().stream().map(PostEdges::getNode).collect(Collectors.toList()); + Assertions.assertThat(actual).containsExactlyInAnyOrder(expected.toArray(PostPayload[]::new)); + } + + @Test + void posts_with_custom_limit() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("limit", 5); + node.put("after", 0); + + GraphQLResponse response = template.perform("post/get-posts.graphql", node); + + assertTrue(response.isOk()); + Posts posts = response.get("$.data.posts", Posts.class); + assertEquals(postList.size(), posts.getTotalCount()); + assertEquals(4, posts.getPageInfo().getEndCursor()); + assertTrue(posts.getPageInfo().getHasNextPage()); + + List expected = postList.subList(0, 5).stream().map(post -> new PostPayload(post.getId(), post.getTitle(), post.getContent())).collect(Collectors.toList()); + List actual = posts.getEdges().stream().map(PostEdges::getNode).collect(Collectors.toList()); + + Assertions.assertThat(actual).containsExactlyInAnyOrder(expected.toArray(PostPayload[]::new)); + } + + @Test + void post() throws IOException { + Post expectedPost = postList.get(0); + List expectedComments = expectedPost.getComments().stream().map(comment -> new CommentPayload(comment.getId(), comment.getContent())).collect(Collectors.toList()); + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", expectedPost.getId()); + + GraphQLResponse response = template.perform("post/get-post.graphql", node); + + assertTrue(response.isOk()); + PostPayload postPayload = response.get("$.data.post", PostPayload.class); + assertEquals(expectedPost.getId(), postPayload.getId()); + assertEquals(expectedPost.getTitle(), postPayload.getTitle()); + assertEquals(expectedPost.getContent(), postPayload.getContent()); + Assertions.assertThat(postPayload.getComments()).containsExactlyInAnyOrder(expectedComments.toArray(CommentPayload[]::new)); + } + + @Test + void post_not_fount() throws IOException { + int notExistentPostId = 56562777; + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + node.put("id", notExistentPostId); + + GraphQLResponse response = template.perform("post/get-post.graphql", node); + + assertTrue(response.isOk()); + assertEquals(String.format("Post with id %d not found", notExistentPostId), response.get("$.errors[0].message")); + } + + private List preparePosts() { + postRepository.deleteAll(); + + List posts = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Post post = new Post("title " + i, "content " + i); + post.addComment(new Comment("comment 1 for post " + i)); + post.addComment(new Comment("comment 2 for post " + i)); + posts.add(post); + } + return postRepository.saveAll(posts); + } +} diff --git a/modules/post/server-java/src/test/resources/comment/add-comment.graphql b/modules/post/server-java/src/test/resources/comment/add-comment.graphql new file mode 100644 index 0000000000..00549ef567 --- /dev/null +++ b/modules/post/server-java/src/test/resources/comment/add-comment.graphql @@ -0,0 +1,6 @@ +mutation addComment($input: AddCommentInput!) { + addComment(input: $input) { + id + content + } +} diff --git a/modules/post/server-java/src/test/resources/comment/delete-comment.graphql b/modules/post/server-java/src/test/resources/comment/delete-comment.graphql new file mode 100644 index 0000000000..57432b80f3 --- /dev/null +++ b/modules/post/server-java/src/test/resources/comment/delete-comment.graphql @@ -0,0 +1,6 @@ +mutation deleteComment($input: DeleteCommentInput!) { + deleteComment(input: $input) { + id + content + } +} diff --git a/modules/post/server-java/src/test/resources/comment/edit-comment.graphql b/modules/post/server-java/src/test/resources/comment/edit-comment.graphql new file mode 100644 index 0000000000..f3a2363965 --- /dev/null +++ b/modules/post/server-java/src/test/resources/comment/edit-comment.graphql @@ -0,0 +1,6 @@ +mutation editComment($input: EditCommentInput!) { + editComment(input: $input) { + id + content + } +} diff --git a/modules/post/server-java/src/test/resources/post/add-post.graphql b/modules/post/server-java/src/test/resources/post/add-post.graphql new file mode 100644 index 0000000000..bb7b898265 --- /dev/null +++ b/modules/post/server-java/src/test/resources/post/add-post.graphql @@ -0,0 +1,11 @@ +mutation addPost($input: AddPostInput!) { + addPost(input: $input) { + id + title + content + comments { + id + content + } + } +} diff --git a/modules/post/server-java/src/test/resources/post/delete-post.graphql b/modules/post/server-java/src/test/resources/post/delete-post.graphql new file mode 100644 index 0000000000..8e6d510614 --- /dev/null +++ b/modules/post/server-java/src/test/resources/post/delete-post.graphql @@ -0,0 +1,11 @@ +mutation deletePost($id: Int!) { + deletePost(id: $id) { + id + title + content + comments { + id + content + } + } +} diff --git a/modules/post/server-java/src/test/resources/post/edit-post.graphql b/modules/post/server-java/src/test/resources/post/edit-post.graphql new file mode 100644 index 0000000000..36becbaf81 --- /dev/null +++ b/modules/post/server-java/src/test/resources/post/edit-post.graphql @@ -0,0 +1,11 @@ +mutation editPost($input: EditPostInput!) { + editPost(input: $input) { + id + title + content + comments { + id + content + } + } +} diff --git a/modules/post/server-java/src/test/resources/post/get-post.graphql b/modules/post/server-java/src/test/resources/post/get-post.graphql new file mode 100644 index 0000000000..d2bbfd9e75 --- /dev/null +++ b/modules/post/server-java/src/test/resources/post/get-post.graphql @@ -0,0 +1,11 @@ +query post($id: Int!) { + post(id: $id) { + id + title + content + comments { + id + content + } + } +} diff --git a/modules/post/server-java/src/test/resources/post/get-posts.graphql b/modules/post/server-java/src/test/resources/post/get-posts.graphql new file mode 100644 index 0000000000..daef511474 --- /dev/null +++ b/modules/post/server-java/src/test/resources/post/get-posts.graphql @@ -0,0 +1,21 @@ +query posts($limit: Int, $after: Int) { + posts(limit: $limit, after: $after) { + totalCount + edges { + cursor + node { + id + title + content + comments { + id + content + } + } + } + pageInfo { + endCursor + hasNextPage + } + } +} diff --git a/modules/reports/server-java/build.gradle b/modules/reports/server-java/build.gradle new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/reports/server-java/src/main/java/com/sysgears/reports/ReportDBInitializer.java b/modules/reports/server-java/src/main/java/com/sysgears/reports/ReportDBInitializer.java new file mode 100644 index 0000000000..fb9356676d --- /dev/null +++ b/modules/reports/server-java/src/main/java/com/sysgears/reports/ReportDBInitializer.java @@ -0,0 +1,39 @@ +package com.sysgears.reports; + +import com.sysgears.reports.model.Report; +import com.sysgears.reports.repository.ReportRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; + + +@Component +@RequiredArgsConstructor +public class ReportDBInitializer { + private final ReportRepository repository; + + @EventListener + public void onApplicationStartedEvent(ApplicationStartedEvent event) { + long count = repository.count(); + if (count == 0) { + Report tomJacksonReport = new Report("Tom Jackson", "555-444-333", "tom@gmail.com"); + Report mikeJamesReport = new Report("Mike James", "555-777-888", "mikejames@gmail.com"); + Report janetLarsonReport = new Report("Janet Larson", "555-222-111", "janetlarson@gmail.com"); + Report clarkThompsonReport = new Report("Clark Thompson", "555-444-333", "clark123@gmail.com"); + Report emmaPageReport = new Report("Emma Page", "555-444-333", "emma1page@gmail.com"); + + repository.saveAll( + List.of( + tomJacksonReport, + mikeJamesReport, + janetLarsonReport, + clarkThompsonReport, + emmaPageReport + ) + ); + } + } +} diff --git a/modules/reports/server-java/src/main/java/com/sysgears/reports/dto/ReportPayload.java b/modules/reports/server-java/src/main/java/com/sysgears/reports/dto/ReportPayload.java new file mode 100644 index 0000000000..c6ebca47b4 --- /dev/null +++ b/modules/reports/server-java/src/main/java/com/sysgears/reports/dto/ReportPayload.java @@ -0,0 +1,20 @@ +package com.sysgears.reports.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReportPayload { + @NonNull + private Integer id; + @NonNull + private String name; + @NonNull + private String phone; + @NonNull + private String email; +} diff --git a/modules/reports/server-java/src/main/java/com/sysgears/reports/model/Report.java b/modules/reports/server-java/src/main/java/com/sysgears/reports/model/Report.java new file mode 100644 index 0000000000..5c48651ae9 --- /dev/null +++ b/modules/reports/server-java/src/main/java/com/sysgears/reports/model/Report.java @@ -0,0 +1,33 @@ +package com.sysgears.reports.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "REPORT") +public class Report { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "NAME", nullable = false) + private String name; + + @Column(name = "PHONE", nullable = false) + private String phone; + + @Column(name = "EMAIL", nullable = false) + private String email; + + public Report(String name, String phone, String email) { + this.name = name; + this.phone = phone; + this.email = email; + } +} diff --git a/modules/reports/server-java/src/main/java/com/sysgears/reports/repository/ReportRepository.java b/modules/reports/server-java/src/main/java/com/sysgears/reports/repository/ReportRepository.java new file mode 100644 index 0000000000..5aa80d8ac8 --- /dev/null +++ b/modules/reports/server-java/src/main/java/com/sysgears/reports/repository/ReportRepository.java @@ -0,0 +1,7 @@ +package com.sysgears.reports.repository; + +import com.sysgears.reports.model.Report; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { +} diff --git a/modules/reports/server-java/src/main/java/com/sysgears/reports/resolvers/ReportQueryResolver.java b/modules/reports/server-java/src/main/java/com/sysgears/reports/resolvers/ReportQueryResolver.java new file mode 100644 index 0000000000..636aebaa22 --- /dev/null +++ b/modules/reports/server-java/src/main/java/com/sysgears/reports/resolvers/ReportQueryResolver.java @@ -0,0 +1,20 @@ +package com.sysgears.reports.resolvers; + +import com.sysgears.reports.model.Report; +import com.sysgears.reports.repository.ReportRepository; +import graphql.kickstart.tools.GraphQLQueryResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class ReportQueryResolver implements GraphQLQueryResolver { + private final ReportRepository reportRepository; + + public CompletableFuture> report() { + return CompletableFuture.supplyAsync(reportRepository::findAll); + } +} diff --git a/modules/reports/server-java/src/main/resources/schema.graphqls b/modules/reports/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..0362747df6 --- /dev/null +++ b/modules/reports/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,12 @@ +# Report +type Report { + id: Int! + name: String! + phone: String! + email: String! +} + +extend type Query { + #Report + report: [Report] +} diff --git a/modules/upload/server-java/build.gradle b/modules/upload/server-java/build.gradle new file mode 100644 index 0000000000..e4dbb7fe36 --- /dev/null +++ b/modules/upload/server-java/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':core') +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/config/UploadScalarConfiguration.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/config/UploadScalarConfiguration.java new file mode 100644 index 0000000000..fc6cf72163 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/config/UploadScalarConfiguration.java @@ -0,0 +1,45 @@ +package com.sysgears.upload.config; + +import graphql.schema.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.servlet.http.Part; + +@Configuration +public class UploadScalarConfiguration { + + @Bean + public GraphQLScalarType uploadScalar() { + return GraphQLScalarType.newScalar() + .name("FileUpload") + .description("A file part in a multipart request") + .coercing(new Coercing() { + @Override + public Void serialize(Object dataFetcherResult) { + throw new CoercingSerializeException("Upload is an input-only type"); + } + + @Override + public Part parseValue(Object input) { + if (input instanceof Part) { + return (Part) input; + } else if (null == input) { + return null; + } else { + throw new CoercingParseValueException("Expected type " + + Part.class.getName() + + " but was " + + input.getClass().getName()); + } + } + + @Override + public Part parseLiteral(Object input) { + throw new CoercingParseLiteralException( + "Must use variables to specify Upload values"); + } + }) + .build(); + } +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/dto/File.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/dto/File.java new file mode 100644 index 0000000000..ec2eb918f0 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/dto/File.java @@ -0,0 +1,22 @@ +package com.sysgears.upload.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class File { + @NonNull + private Integer id; + @NonNull + private String name; + @NonNull + private String type; + @NonNull + private Long size; + @NonNull + private String path; +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/exception/FileNotFoundException.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/exception/FileNotFoundException.java new file mode 100644 index 0000000000..f80e20d167 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/exception/FileNotFoundException.java @@ -0,0 +1,8 @@ +package com.sysgears.upload.exception; + +public class FileNotFoundException extends RuntimeException { + + public FileNotFoundException(Integer id) { + super(String.format("File with id %d not found", id)); + } +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/file/FileStorage.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/file/FileStorage.java new file mode 100644 index 0000000000..ebded48911 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/file/FileStorage.java @@ -0,0 +1,10 @@ +package com.sysgears.upload.file; + +import javax.servlet.http.Part; + +public interface FileStorage { + + String writeFile(String fileName, Part part); + + void deleteFile(String filePath); +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/file/LocalFileStorage.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/file/LocalFileStorage.java new file mode 100644 index 0000000000..34c25ee9b6 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/file/LocalFileStorage.java @@ -0,0 +1,38 @@ +package com.sysgears.upload.file; + +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.servlet.http.Part; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Slf4j +@Component +public class LocalFileStorage implements FileStorage { + private static final String FILES_DIRECTORY = "files"; + + @Override + @SneakyThrows + public String writeFile(String fileName, Part part) { + final Path filePath = Paths.get(FILES_DIRECTORY).resolve(fileName); + filePath.getParent().toFile().mkdirs(); //create directories if not exist + + File file = filePath.toFile(); + + part.write(file.getAbsolutePath()); + log.debug("File write to '{}'", file.getPath()); + + return file.getPath(); + } + + @Override + @SneakyThrows + public void deleteFile(String filePath) { + log.debug("Deleting file '{}'", filePath); + Files.deleteIfExists(Paths.get(filePath)); + } +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/model/FileMetadata.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/model/FileMetadata.java new file mode 100644 index 0000000000..dad23c3f8c --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/model/FileMetadata.java @@ -0,0 +1,37 @@ +package com.sysgears.upload.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "FILES") +public class FileMetadata { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "NAME", nullable = false) + private String name; + + @Column(name = "CONTENT_TYPE", nullable = false) + private String contentType; + + @Column(name = "SIZE", nullable = false) + private Long size; + + @Column(name = "PATH", nullable = false) + private String path; + + public FileMetadata(String name, String contentType, Long size, String path) { + this.name = name; + this.contentType = contentType; + this.size = size; + this.path = path; + } +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/repository/FileMetadataRepository.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/repository/FileMetadataRepository.java new file mode 100644 index 0000000000..cb526b1806 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/repository/FileMetadataRepository.java @@ -0,0 +1,7 @@ +package com.sysgears.upload.repository; + +import com.sysgears.upload.model.FileMetadata; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FileMetadataRepository extends JpaRepository { +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/resolvers/UploadMutationResolver.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/resolvers/UploadMutationResolver.java new file mode 100644 index 0000000000..124344f82a --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/resolvers/UploadMutationResolver.java @@ -0,0 +1,29 @@ +package com.sysgears.upload.resolvers; + +import com.sysgears.upload.service.FileService; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.servlet.http.Part; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class UploadMutationResolver implements GraphQLMutationResolver { + private final FileService fileService; + + public boolean uploadFiles(List files) { + for (Part file : files) { + fileService.create(file); + } + + return true; + } + + public boolean removeFile(Integer id) { + fileService.deleteById(id); + + return true; + } +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/resolvers/UploadQueryResolver.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/resolvers/UploadQueryResolver.java new file mode 100644 index 0000000000..7cfbf5c30f --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/resolvers/UploadQueryResolver.java @@ -0,0 +1,31 @@ +package com.sysgears.upload.resolvers; + +import com.sysgears.upload.dto.File; +import com.sysgears.upload.repository.FileMetadataRepository; +import graphql.kickstart.tools.GraphQLQueryResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class UploadQueryResolver implements GraphQLQueryResolver { + private final FileMetadataRepository fileMetadataRepository; + + public CompletableFuture> files() { + return CompletableFuture.supplyAsync(() -> + fileMetadataRepository.findAll().stream() + .map(file -> new File( + file.getId(), + file.getName(), + file.getContentType(), + file.getSize(), + file.getPath() + )) + .collect(Collectors.toList()) + ); + } +} diff --git a/modules/upload/server-java/src/main/java/com/sysgears/upload/service/FileService.java b/modules/upload/server-java/src/main/java/com/sysgears/upload/service/FileService.java new file mode 100644 index 0000000000..64a4327522 --- /dev/null +++ b/modules/upload/server-java/src/main/java/com/sysgears/upload/service/FileService.java @@ -0,0 +1,33 @@ +package com.sysgears.upload.service; + +import com.sysgears.upload.exception.FileNotFoundException; +import com.sysgears.upload.file.FileStorage; +import com.sysgears.upload.model.FileMetadata; +import com.sysgears.upload.repository.FileMetadataRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.servlet.http.Part; + +@Service +@Transactional +@RequiredArgsConstructor +public class FileService { + private final FileMetadataRepository fileMetadataRepository; + private final FileStorage fileStorage; + + public FileMetadata create(Part file) { + String fileName = file.getSubmittedFileName().replace(" ", "_"); + String path = fileStorage.writeFile(fileName, file); + + return fileMetadataRepository.save(new FileMetadata(fileName, file.getContentType(), file.getSize(), path)); + } + + public void deleteById(Integer id) { + FileMetadata fileMetadata = fileMetadataRepository.findById(id).orElseThrow(()-> new FileNotFoundException(id)); + + fileStorage.deleteFile(fileMetadata.getPath()); + fileMetadataRepository.deleteById(id); + } +} diff --git a/modules/upload/server-java/src/main/resources/schema.graphqls b/modules/upload/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..9dcb82743a --- /dev/null +++ b/modules/upload/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,19 @@ +scalar FileUpload + +type File { + id: Int! + name: String! + type: String! + size: Int! + path: String! +} + +extend type Query { + files: [File] +} + +extend type Mutation { + uploadFiles(files: [FileUpload]!): Boolean! + + removeFile(id: Int!): Boolean! +} diff --git a/modules/upload/server-java/src/test/java/com/sysgears/upload/resolvers/UploadMutationResolverTest.java b/modules/upload/server-java/src/test/java/com/sysgears/upload/resolvers/UploadMutationResolverTest.java new file mode 100644 index 0000000000..8ce7594377 --- /dev/null +++ b/modules/upload/server-java/src/test/java/com/sysgears/upload/resolvers/UploadMutationResolverTest.java @@ -0,0 +1,110 @@ +package com.sysgears.upload.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.upload.model.FileMetadata; +import com.sysgears.upload.repository.FileMetadataRepository; +import org.apache.tomcat.util.http.fileupload.FileUtils; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class UploadMutationResolverTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private TestRestTemplate restTemplate; + @Autowired + private FileMetadataRepository fileMetadataRepository; + + @Test + void uploadFiles() throws Exception { + List files = prepare("file1", "file2"); + + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("operations", "{ \"query\": \"mutation uploadFiles($files: [FileUpload!]!) { uploadFiles(files: $files) }\", \"variables\": { \"files\": [null, null] } }"); + params.add("map", "{\"0\": [\"variables.files.0\"], \"1\": [\"variables.files.1\"]}"); + params.add("0", new FileSystemResource(files.get(0))); + params.add("1", new FileSystemResource(files.get(1))); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + ResponseEntity response = restTemplate.exchange( + "/graphql", + HttpMethod.POST, + new HttpEntity<>(params, headers), + String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + + List all = fileMetadataRepository.findAll(); + assertEquals(2, all.size()); + assertEquals(files.get(0).getName(), all.get(0).getName()); + assertEquals(files.get(1).getName(), all.get(1).getName()); + + // remove directories and files after test + FileUtils.deleteDirectory(files.get(0).getParentFile()); + FileUtils.deleteDirectory(new File(all.get(0).getPath()).getParentFile()); + } + + @Test + void removeFile() throws Exception { + File file = prepare("file1").get(0); + + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("operations", "{ \"query\": \"mutation uploadFiles($files: [FileUpload!]!) { uploadFiles(files: $files) }\", \"variables\": { \"files\": [null] } }"); + params.add("map", "{\"0\": [\"variables.files.0\"]}"); + params.add("0", new FileSystemResource(file)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + restTemplate.exchange( + "/graphql", + HttpMethod.POST, + new HttpEntity<>(params, headers), + String.class); + + List all = fileMetadataRepository.findAll(); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", all.get(0).getId()); + + template.perform("remove-file.graphql", node); + + assertFalse(fileMetadataRepository.existsById(all.get(0).getId())); + + FileUtils.deleteDirectory(file.getParentFile()); + } + + private List prepare(String... filenames) throws IOException { + List files = new ArrayList<>(); + Paths.get("src/test/resources/upload-files/").toFile().mkdirs(); //create directories if not exist + for (String filename : filenames) { + FileWriter writer = new FileWriter("src/test/resources/upload-files/" + filename + ".txt"); + writer.write("some file content"); + writer.close(); + files.add(new File("src/test/resources/upload-files/" + filename + ".txt")); + } + return files; + } +} diff --git a/modules/upload/server-java/src/test/java/com/sysgears/upload/resolvers/UploadQueryResolverTest.java b/modules/upload/server-java/src/test/java/com/sysgears/upload/resolvers/UploadQueryResolverTest.java new file mode 100644 index 0000000000..9fed3f3116 --- /dev/null +++ b/modules/upload/server-java/src/test/java/com/sysgears/upload/resolvers/UploadQueryResolverTest.java @@ -0,0 +1,71 @@ +package com.sysgears.upload.resolvers; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.upload.dto.File; +import com.sysgears.upload.model.FileMetadata; +import com.sysgears.upload.repository.FileMetadataRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +class UploadQueryResolverTest { + @Autowired + private GraphQLTestTemplate template; + @MockBean + private FileMetadataRepository fileMetadataRepository; + + @Test + void files() throws IOException { + FileMetadata jpegMetadata = new FileMetadata( + "filename", + "image/jpeg", + 123L, + "/fake_path/image.jpeg" + ); + jpegMetadata.setId(1); + FileMetadata pngMetadata = new FileMetadata( + "filename2", + "image/png", + 456L, + "/fake_path/image.png" + ); + pngMetadata.setId(2); + when(fileMetadataRepository.findAll()).thenReturn(List.of(jpegMetadata, pngMetadata)); + + GraphQLResponse response = template.postForResource("get-all-files.graphql"); + assertTrue(response.isOk()); + + String responseBody = response.getRawResponse().getBody(); + ObjectMapper mapper = new ObjectMapper(); + JsonParser filesJson = mapper.readTree(responseBody).findPath("files").traverse(); + List files = mapper.readValue(filesJson, new TypeReference<>() { + }); + + assertEquals(2, files.size()); + assertEquals(jpegMetadata.getId(), files.get(0).getId()); + assertEquals(jpegMetadata.getName(), files.get(0).getName()); + assertEquals(jpegMetadata.getContentType(), files.get(0).getType()); + assertEquals(jpegMetadata.getSize(), files.get(0).getSize()); + assertEquals(jpegMetadata.getPath(), files.get(0).getPath()); + assertEquals(pngMetadata.getId(), files.get(1).getId()); + assertEquals(pngMetadata.getName(), files.get(1).getName()); + assertEquals(pngMetadata.getContentType(), files.get(1).getType()); + assertEquals(pngMetadata.getSize(), files.get(1).getSize()); + assertEquals(pngMetadata.getPath(), files.get(1).getPath()); + } +} diff --git a/modules/upload/server-java/src/test/resources/get-all-files.graphql b/modules/upload/server-java/src/test/resources/get-all-files.graphql new file mode 100644 index 0000000000..90605374fa --- /dev/null +++ b/modules/upload/server-java/src/test/resources/get-all-files.graphql @@ -0,0 +1,9 @@ +query files { + files { + id + name + type + size + path + } +} diff --git a/modules/upload/server-java/src/test/resources/remove-file.graphql b/modules/upload/server-java/src/test/resources/remove-file.graphql new file mode 100644 index 0000000000..e06be69dbd --- /dev/null +++ b/modules/upload/server-java/src/test/resources/remove-file.graphql @@ -0,0 +1,3 @@ +mutation removeFile($id: Int!) { + removeFile(id: $id) +} diff --git a/modules/upload/server-java/src/test/resources/upload-files.graphql b/modules/upload/server-java/src/test/resources/upload-files.graphql new file mode 100644 index 0000000000..06d8224db1 --- /dev/null +++ b/modules/upload/server-java/src/test/resources/upload-files.graphql @@ -0,0 +1,4 @@ +mutation uploadFiles($files: [FileUpload]!) { + uploadFiles(files: $files) { + } +} diff --git a/modules/user/server-java/build.gradle b/modules/user/server-java/build.gradle new file mode 100644 index 0000000000..1a0cfce89f --- /dev/null +++ b/modules/user/server-java/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation project(':core') + api project(':authentication') + implementation project(':mailer') +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/UserDBInitializer.java b/modules/user/server-java/src/main/java/com/sysgears/user/UserDBInitializer.java new file mode 100644 index 0000000000..ec82a325de --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/UserDBInitializer.java @@ -0,0 +1,34 @@ +package com.sysgears.user; + +import com.sysgears.user.model.User; +import com.sysgears.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserDBInitializer { + private final UserRepository repository; + private final PasswordEncoder passwordEncoder; + + @EventListener + public void onApplicationStartedEvent(ApplicationStartedEvent event) { + long count = repository.count(); + if (count == 0) { + String encodedAdminPassword = passwordEncoder.encode("admin123"); + String encodedUserPassword = passwordEncoder.encode("user1234"); + + User admin = new User("admin", encodedAdminPassword, "admin", true, "admin@example.com"); + User user = new User("user", encodedUserPassword, "user", true, "user@example.com"); + repository.saveAll(List.of(admin, user)); + log.debug("Users initialized"); + } + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/config/JWTPreAuthenticationToken.java b/modules/user/server-java/src/main/java/com/sysgears/user/config/JWTPreAuthenticationToken.java new file mode 100644 index 0000000000..0d8536e711 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/config/JWTPreAuthenticationToken.java @@ -0,0 +1,15 @@ +package com.sysgears.user.config; + +import com.sysgears.user.model.User; +import lombok.Getter; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +@Getter +public class JWTPreAuthenticationToken extends PreAuthenticatedAuthenticationToken { + + public JWTPreAuthenticationToken(User principal, WebAuthenticationDetails details) { + super(principal, null, principal.getAuthorities()); + super.setDetails(details); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/config/JwtFilter.java b/modules/user/server-java/src/main/java/com/sysgears/user/config/JwtFilter.java new file mode 100644 index 0000000000..2f904ee22d --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/config/JwtFilter.java @@ -0,0 +1,44 @@ +package com.sysgears.user.config; + +import com.sysgears.authentication.utils.SessionUtils; +import com.sysgears.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final Pattern BEARER_PATTERN = Pattern.compile("^Bearer (.+?)$"); + private final UserService userService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + getToken(request) + .map(userService::loadUserByToken) + .map(user -> new JWTPreAuthenticationToken(user, new WebAuthenticationDetailsSource().buildDetails(request))) + .ifPresent(SessionUtils.SECURITY_CONTEXT::setAuthentication); + filterChain.doFilter(request, response); + } + + private Optional getToken(HttpServletRequest request) { + return Optional + .ofNullable(request.getHeader(AUTHORIZATION_HEADER)) + .filter(Predicate.not(String::isEmpty)) + .map(BEARER_PATTERN::matcher) + .filter(Matcher::find) + .map(matcher -> matcher.group(1)); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/config/SecurityConfig.java b/modules/user/server-java/src/main/java/com/sysgears/user/config/SecurityConfig.java new file mode 100644 index 0000000000..0e54112912 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/config/SecurityConfig.java @@ -0,0 +1,61 @@ +package com.sysgears.user.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@RequiredArgsConstructor +// if async mode for graphQL will be disabled, spring security @PreAuthorize can be used +// graphql-spring-boot-starter from version 8.0.0 use graphql.execution.AsyncExecutionStrategy by default. +//@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig extends WebSecurityConfigurerAdapter { + private final UserDetailsService userService; + private final JwtFilter jwtFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Override + protected void configure(final AuthenticationManagerBuilder auth) throws Exception { + auth.userDetailsService(userService).passwordEncoder(passwordEncoder()); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.csrf().disable() + .authorizeRequests().anyRequest().permitAll() + .and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .addFilterBefore(jwtFilter, RequestHeaderAuthenticationFilter.class) + .cors().configurationSource(securityCorsConfiguration()); + } + + @Bean + public CorsConfigurationSource securityCorsConfiguration() { + final CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOrigin("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/AuthPayload.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/AuthPayload.java new file mode 100644 index 0000000000..2faa423f0e --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/AuthPayload.java @@ -0,0 +1,15 @@ +package com.sysgears.user.dto; + +import com.sysgears.authentication.model.jwt.Tokens; +import com.sysgears.user.model.User; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthPayload { + private User user; + private Tokens tokens; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/UpdateUserPayload.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/UpdateUserPayload.java new file mode 100644 index 0000000000..1720937cc5 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/UpdateUserPayload.java @@ -0,0 +1,10 @@ +package com.sysgears.user.dto; + +import com.sysgears.user.model.User; +import lombok.Data; + +@Data +public class UpdateUserPayload { + private final String mutation; + private final User node; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/UserPayload.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/UserPayload.java new file mode 100644 index 0000000000..5d201fdd22 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/UserPayload.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto; + +import com.sysgears.user.model.User; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserPayload { + private User user; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/AddUserInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/AddUserInput.java new file mode 100644 index 0000000000..99e5dc3ce3 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/AddUserInput.java @@ -0,0 +1,26 @@ +package com.sysgears.user.dto.input; + +import com.sysgears.user.dto.input.auth.AuthInput; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AddUserInput { + @NonNull + private String username; + @NonNull + private String password; + @NonNull + private String role; + @NonNull + private String email; + private final Boolean isActive = false; + private final Optional profile = Optional.empty(); + private final Optional auth = Optional.empty(); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/EditUserInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/EditUserInput.java new file mode 100644 index 0000000000..0ba83354ae --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/EditUserInput.java @@ -0,0 +1,27 @@ +package com.sysgears.user.dto.input; + +import com.sysgears.user.dto.input.auth.AuthInput; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EditUserInput { + @NonNull + private int id; + @NonNull + private String username; + @NonNull + private String role; + @NonNull + private String email; + private final Optional isActive = Optional.empty(); + private final Optional password = Optional.empty(); + private final Optional profile = Optional.empty(); + private final Optional auth = Optional.empty(); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/FilterUserInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/FilterUserInput.java new file mode 100644 index 0000000000..c7446b27f4 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/FilterUserInput.java @@ -0,0 +1,15 @@ +package com.sysgears.user.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +public class FilterUserInput { + private final Optional searchText = Optional.empty(); + private final Optional role = Optional.empty(); + private final Optional isActive = Optional.empty(); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ForgotPasswordInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ForgotPasswordInput.java new file mode 100644 index 0000000000..24fd299249 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ForgotPasswordInput.java @@ -0,0 +1,14 @@ +package com.sysgears.user.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ForgotPasswordInput { + @NonNull + private String email; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/LoginUserInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/LoginUserInput.java new file mode 100644 index 0000000000..dad5eaf822 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/LoginUserInput.java @@ -0,0 +1,14 @@ +package com.sysgears.user.dto.input; + +import lombok.*; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginUserInput { + @NonNull + private String usernameOrEmail; + @NonNull + private String password; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/OrderByUserInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/OrderByUserInput.java new file mode 100644 index 0000000000..9038202c0f --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/OrderByUserInput.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto.input; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +public class OrderByUserInput { + private final Optional column = Optional.empty(); + private final Optional order = Optional.empty(); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ProfileInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ProfileInput.java new file mode 100644 index 0000000000..3928088102 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ProfileInput.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto.input; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +public class ProfileInput { + private final Optional firstName = Optional.empty(); + private final Optional lastName = Optional.empty(); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/RegisterUserInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/RegisterUserInput.java new file mode 100644 index 0000000000..23822f69ab --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/RegisterUserInput.java @@ -0,0 +1,18 @@ +package com.sysgears.user.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.lang.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RegisterUserInput { + @NonNull + private String username; + @NonNull + private String email; + @NonNull + private String password; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ResetPasswordInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ResetPasswordInput.java new file mode 100644 index 0000000000..d17b498b5b --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/ResetPasswordInput.java @@ -0,0 +1,18 @@ +package com.sysgears.user.dto.input; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ResetPasswordInput { + @NonNull + private String token; + @NonNull + private String password; + @NonNull + private String passwordConfirmation; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthCertificateInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthCertificateInput.java new file mode 100644 index 0000000000..d77fffcb29 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthCertificateInput.java @@ -0,0 +1,12 @@ +package com.sysgears.user.dto.input.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthCertificateInput { + private String serial; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthFacebookInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthFacebookInput.java new file mode 100644 index 0000000000..cdd32e3d8b --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthFacebookInput.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto.input.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthFacebookInput { + private String fbId; + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthGitHubInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthGitHubInput.java new file mode 100644 index 0000000000..3710c81336 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthGitHubInput.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto.input.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthGitHubInput { + private String ghId; + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthGoogleInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthGoogleInput.java new file mode 100644 index 0000000000..85f3263e57 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthGoogleInput.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto.input.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthGoogleInput { + private String googleId; + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthInput.java new file mode 100644 index 0000000000..d0b5335009 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthInput.java @@ -0,0 +1,16 @@ +package com.sysgears.user.dto.input.auth; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +public class AuthInput { + private final Optional certificate = Optional.empty(); + private final Optional facebook = Optional.empty(); + private final Optional google = Optional.empty(); + private final Optional github = Optional.empty(); + private final Optional linkedin = Optional.empty(); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthLinkedInInput.java b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthLinkedInInput.java new file mode 100644 index 0000000000..d35ec83415 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/dto/input/auth/AuthLinkedInInput.java @@ -0,0 +1,13 @@ +package com.sysgears.user.dto.input.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthLinkedInInput { + private String lnId; + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/exception/LoginFailedException.java b/modules/user/server-java/src/main/java/com/sysgears/user/exception/LoginFailedException.java new file mode 100644 index 0000000000..4a6555d924 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/exception/LoginFailedException.java @@ -0,0 +1,12 @@ +package com.sysgears.user.exception; + +import com.sysgears.core.exception.FieldErrorException; + +import java.util.Map; + +public class LoginFailedException extends FieldErrorException { + + public LoginFailedException(String message, Map errors) { + super(message, errors); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/exception/ResetPasswordException.java b/modules/user/server-java/src/main/java/com/sysgears/user/exception/ResetPasswordException.java new file mode 100644 index 0000000000..8f308b6509 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/exception/ResetPasswordException.java @@ -0,0 +1,12 @@ +package com.sysgears.user.exception; + +import com.sysgears.core.exception.FieldErrorException; + +import java.util.Map; + +public class ResetPasswordException extends FieldErrorException { + + public ResetPasswordException(String message, Map errors) { + super(message, errors); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserAlreadyExistsException.java b/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000000..0081403071 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserAlreadyExistsException.java @@ -0,0 +1,12 @@ +package com.sysgears.user.exception; + +import com.sysgears.core.exception.FieldErrorException; + +import java.util.Map; + +public class UserAlreadyExistsException extends FieldErrorException { + + public UserAlreadyExistsException(String message, Map errors) { + super(message, errors); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserDeletionException.java b/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserDeletionException.java new file mode 100644 index 0000000000..bde035ef40 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserDeletionException.java @@ -0,0 +1,8 @@ +package com.sysgears.user.exception; + +public class UserDeletionException extends RuntimeException { + + public UserDeletionException(String message) { + super(message); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserNotFoundException.java b/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserNotFoundException.java new file mode 100644 index 0000000000..e59e9aebb0 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/exception/UserNotFoundException.java @@ -0,0 +1,20 @@ +package com.sysgears.user.exception; + +import com.sysgears.core.exception.FieldErrorException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.Collections; +import java.util.Map; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class UserNotFoundException extends FieldErrorException { + + public UserNotFoundException(String message) { + super(message, Collections.emptyMap()); + } + + public UserNotFoundException(String message, Map errors) { + super(message, errors); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/User.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/User.java new file mode 100644 index 0000000000..c7c1ef02ec --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/User.java @@ -0,0 +1,84 @@ +package com.sysgears.user.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.GenericGenerator; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import javax.persistence.*; +import java.util.Collection; +import java.util.List; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "USERS") +public class User implements UserDetails { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "USERNAME", nullable = false) + private String username; + + @Column(name = "PASSWORD", nullable = false) + private String password; + + @Column(name = "ROLE", nullable = false) + private String role; + + @Column(name = "IS_ACTIVE") + private Boolean isActive; + + @Column(name = "EMAIL", nullable = false) + private String email; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private UserProfile profile; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private UserAuth auth; + + public User(String username, + String password, + String role, + Boolean isActive, + String email) { + this.username = username; + this.password = password; + this.role = role; + this.isActive = isActive; + this.email = email; + } + + @Override + public Collection getAuthorities() { + return List.of(() -> role); + } + + @Override + public boolean isAccountNonExpired() { + return isActive; + } + + @Override + public boolean isAccountNonLocked() { + return isActive; + } + + @Override + public boolean isCredentialsNonExpired() { + return isActive; + } + + @Override + public boolean isEnabled() { + return isActive; + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/UserAuth.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/UserAuth.java new file mode 100644 index 0000000000..a6b05fdb4f --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/UserAuth.java @@ -0,0 +1,43 @@ +package com.sysgears.user.model; + +import com.sysgears.user.model.auth.*; +import lombok.*; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Table(name = "USER_AUTH") +public class UserAuth { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + @Column(name = "ID") + private int id; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private CertificateAuth certificate; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private FacebookAuth facebook; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private GoogleAuth google; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private GithubAuth github; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(value = FetchMode.JOIN) + private LinkedInAuth linkedin; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/UserProfile.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/UserProfile.java new file mode 100644 index 0000000000..660366e1bf --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/UserProfile.java @@ -0,0 +1,33 @@ +package com.sysgears.user.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "USER_PROFILE") +public class UserProfile { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + private int id; + + @Column(name = "FIRST_NAME") + private String firstName; + + @Column(name = "LAST_NAME") + private String lastName; + + @Column(name = "FULL_NAME") + private String fullName; + + public UserProfile(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + this.fullName = firstName.concat(" ").concat(lastName); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/CertificateAuth.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/CertificateAuth.java new file mode 100644 index 0000000000..66435bfc70 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/CertificateAuth.java @@ -0,0 +1,26 @@ +package com.sysgears.user.model.auth; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +import javax.persistence.*; + +@Entity +@Data +@NoArgsConstructor +@Table(name = "CERTIFICATE_AUTH") +public class CertificateAuth { + @Id + @GeneratedValue(strategy = GenerationType.AUTO, generator = "native") + @GenericGenerator(name = "native", strategy = "native") + @Column(name = "ID") + private int id; + + @Column(name = "SERIAL") + private String serial; + + public CertificateAuth(String serial) { + this.serial = serial; + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/FacebookAuth.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/FacebookAuth.java new file mode 100644 index 0000000000..b96135f6ea --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/FacebookAuth.java @@ -0,0 +1,24 @@ +package com.sysgears.user.model.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "FACEBOOK_AUTH") +public class FacebookAuth { + @Id + @Column(name = "FACEBOOK_ID", nullable = false) + private String fbId; + + @Column(name = "DISPLAY_NAME") + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/GithubAuth.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/GithubAuth.java new file mode 100644 index 0000000000..fed7545e39 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/GithubAuth.java @@ -0,0 +1,24 @@ +package com.sysgears.user.model.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "GITHUB_AUTH") +public class GithubAuth { + @Id + @Column(name = "GITHUB_ID", nullable = false) + private String ghId; + + @Column(name = "DISPLAY_NAME") + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/GoogleAuth.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/GoogleAuth.java new file mode 100644 index 0000000000..78b30be2a6 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/GoogleAuth.java @@ -0,0 +1,24 @@ +package com.sysgears.user.model.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "GOOGLE_AUTH") +public class GoogleAuth { + @Id + @Column(name = "GOOGLE_ID", nullable = false) + private String googleId; + + @Column(name = "DISPLAY_NAME") + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/LinkedInAuth.java b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/LinkedInAuth.java new file mode 100644 index 0000000000..e72af83ecb --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/model/auth/LinkedInAuth.java @@ -0,0 +1,24 @@ +package com.sysgears.user.model.auth; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "LINKEDIN_AUTH") +public class LinkedInAuth { + @Id + @Column(name = "LINKEDIN_ID", nullable = false) + private String lnId; + + @Column(name = "DISPLAY_NAME") + private String displayName; +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/repository/CustomUserRepository.java b/modules/user/server-java/src/main/java/com/sysgears/user/repository/CustomUserRepository.java new file mode 100644 index 0000000000..467bee2d80 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/repository/CustomUserRepository.java @@ -0,0 +1,16 @@ +package com.sysgears.user.repository; + +import com.sysgears.user.dto.input.FilterUserInput; +import com.sysgears.user.dto.input.OrderByUserInput; +import com.sysgears.user.model.User; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public interface CustomUserRepository { + + CompletableFuture> findByCriteria(Optional orderBy, Optional filter); + + CompletableFuture findByUsernameOrEmail(String usernameOrEmail); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/repository/UserRepository.java b/modules/user/server-java/src/main/java/com/sysgears/user/repository/UserRepository.java new file mode 100644 index 0000000000..a121e93809 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/repository/UserRepository.java @@ -0,0 +1,16 @@ +package com.sysgears.user.repository; + +import com.sysgears.user.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.scheduling.annotation.Async; + +import java.util.concurrent.CompletableFuture; + +public interface UserRepository extends JpaRepository, CustomUserRepository { + @Async + CompletableFuture findUserById(int id); + + Boolean existsByEmail(String email); + + Boolean existsByUsername(String username); +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/repository/UserRepositoryImpl.java b/modules/user/server-java/src/main/java/com/sysgears/user/repository/UserRepositoryImpl.java new file mode 100644 index 0000000000..0c0260bc8c --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/repository/UserRepositoryImpl.java @@ -0,0 +1,76 @@ +package com.sysgears.user.repository; + +import com.sysgears.user.dto.input.FilterUserInput; +import com.sysgears.user.dto.input.OrderByUserInput; +import com.sysgears.user.model.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import javax.persistence.EntityManager; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements CustomUserRepository { + private final EntityManager entityManager; + + @Override + public CompletableFuture> findByCriteria(Optional orderBy, Optional filter) { + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(User.class); + + Root user = query.from(User.class); + List predicates = new ArrayList<>(); + + filter.ifPresent(filterUserInput -> { + filterUserInput.getRole().filter(s -> !s.isBlank()) + .ifPresent(role -> predicates.add(builder.like(user.get("role"), role))); + filterUserInput.getIsActive() + .ifPresent(isActive -> predicates.add(builder.equal(user.get("isActive"), isActive))); + filterUserInput.getSearchText().filter(s -> !s.isBlank()) + .ifPresent(searchText -> predicates.add(builder.or( + builder.like(user.get("username"), searchText), + builder.like(user.get("email"), searchText) + ))); + }); + + orderBy.ifPresent(orderByUserInput -> { + String orderColumn = orderByUserInput.getColumn() + .filter(s -> !s.isBlank()) + .orElse("id"); + if (orderByUserInput.getOrder().filter(s -> !s.isBlank()).isPresent() && orderByUserInput.getOrder().get().toLowerCase().equals("desc")) { + query.orderBy(builder.desc(user.get(orderColumn))); + } else { + query.orderBy(builder.asc(user.get(orderColumn))); + } + }); + query.where(predicates.toArray(new Predicate[0])); + + return CompletableFuture.supplyAsync(() -> entityManager.createQuery(query).getResultList()); + } + + @Override + public CompletableFuture findByUsernameOrEmail(String usernameOrEmail) { + return CompletableFuture.supplyAsync(() -> { + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + + CriteriaQuery query = builder.createQuery(User.class); + + Root user = query.from(User.class); + + query.where(builder.or( + builder.equal(user.get("username"), usernameOrEmail), + builder.equal(user.get("email"), usernameOrEmail) + )); + + return entityManager.createQuery(query).getSingleResult(); + }); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserMutationResolver.java b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserMutationResolver.java new file mode 100644 index 0000000000..5c26d354fb --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserMutationResolver.java @@ -0,0 +1,160 @@ +package com.sysgears.user.resolvers; + +import com.sysgears.authentication.utils.SessionUtils; +import com.sysgears.core.subscription.Publisher; +import com.sysgears.service.MessageResolver; +import com.sysgears.user.dto.UserPayload; +import com.sysgears.user.dto.input.AddUserInput; +import com.sysgears.user.dto.input.EditUserInput; +import com.sysgears.user.dto.input.ProfileInput; +import com.sysgears.user.dto.input.auth.AuthInput; +import com.sysgears.user.exception.UserDeletionException; +import com.sysgears.user.exception.UserNotFoundException; +import com.sysgears.user.model.User; +import com.sysgears.user.model.UserAuth; +import com.sysgears.user.model.UserProfile; +import com.sysgears.user.model.auth.*; +import com.sysgears.user.service.UserService; +import com.sysgears.user.subscription.UserUpdatedEvent; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserMutationResolver implements GraphQLMutationResolver { + private final UserService userService; + private final Publisher publisher; + private final PasswordEncoder passwordEncoder; + private final MessageResolver messageResolver; + + public CompletableFuture addUser(AddUserInput input) { + return CompletableFuture.supplyAsync(() -> { + User user = new User( + input.getUsername(), + passwordEncoder.encode(input.getPassword()), + input.getRole(), + input.getIsActive(), + input.getEmail() + ); + input.getProfile().map(profileInput -> + new UserProfile( + profileInput.getFirstName().orElse(""), + profileInput.getLastName().orElse("") + ) + ).ifPresent(user::setProfile); + + input.getAuth() + .map(this::from) + .ifPresent(user::setAuth); + userService.save(user); + + publisher.publish(new UserUpdatedEvent(UserUpdatedEvent.Mutation.ADD_USER, user)); + return new UserPayload(user); + }); + } + + public CompletableFuture editUser(EditUserInput input) { + return userService.findUserById(input.getId()) + .thenApplyAsync(user -> { + if (user == null) + throw new UserNotFoundException(messageResolver.getLocalisedMessage("errors.userWithIdNotExists", input.getId())); + + user.setUsername(input.getUsername()); + user.setRole(input.getRole()); + user.setEmail(input.getEmail()); + + input.getIsActive().ifPresent(user::setIsActive); + input.getPassword().ifPresent(password -> user.setPassword(passwordEncoder.encode(password))); + + if (user.getProfile() != null) { + input.getProfile().ifPresent(profile -> { + profile.getFirstName().ifPresent(fn -> user.getProfile().setFirstName(fn)); + profile.getLastName().ifPresent(ln -> user.getProfile().setLastName(ln)); + + user.getProfile().setFullName( + user.getProfile().getFirstName() + .concat(" ") + .concat(user.getProfile().getLastName()) + ); + }); + } else { + input.getProfile().map(this::from).ifPresent(user::setProfile); + } + + if (user.getAuth() != null) { + input.getAuth().ifPresent(authInput -> { + authInput.getCertificate().ifPresent(cert -> + user.getAuth().setCertificate(new CertificateAuth(cert.getSerial()))); + authInput.getFacebook().ifPresent(fb -> + user.getAuth().setFacebook(new FacebookAuth(fb.getFbId(), fb.getDisplayName()))); + authInput.getLinkedin().ifPresent(li -> + user.getAuth().setLinkedin(new LinkedInAuth(li.getLnId(), li.getDisplayName()))); + authInput.getGoogle().ifPresent(g -> + user.getAuth().setGoogle(new GoogleAuth(g.getGoogleId(), g.getDisplayName()))); + authInput.getGithub().ifPresent(git -> + user.getAuth().setGithub(new GithubAuth(git.getGhId(), git.getDisplayName()))); + }); + } else { + input.getAuth().map(this::from).ifPresent(user::setAuth); + } + + userService.save(user); + + publisher.publish(new UserUpdatedEvent(UserUpdatedEvent.Mutation.EDIT_USER, user)); + + return new UserPayload(user); + }); + } + + public CompletableFuture deleteUser(int id) { + return userService.findUserById(id).thenApplyAsync(user -> { + if (user == null) { + throw new UserNotFoundException(messageResolver.getLocalisedMessage("errors.userWithIdNotExists", id)); + } + + Authentication authentication = SessionUtils.SECURITY_CONTEXT.getAuthentication(); + if (authentication == null) { + throw new UserDeletionException(messageResolver.getLocalisedMessage("errors.deleteUser.notEnoughPermission")); + } else if (!((User) authentication.getPrincipal()).getRole().equals("admin")) { + throw new UserDeletionException(messageResolver.getLocalisedMessage("errors.deleteUser.notEnoughPermission")); + } else if (((User) authentication.getPrincipal()).getId() == user.getId()) { + throw new UserDeletionException(messageResolver.getLocalisedMessage("errors.deleteUser.cannotDeleteYourself")); + } + + userService.delete(user); + + publisher.publish(new UserUpdatedEvent(UserUpdatedEvent.Mutation.DELETE_USER, user)); + + return new UserPayload(user); + }); + } + + private UserAuth from(AuthInput authInput) { + UserAuth.UserAuthBuilder builder = UserAuth.builder(); + authInput.getCertificate() + .ifPresent(cert -> builder.certificate(new CertificateAuth(cert.getSerial()))); + authInput.getFacebook() + .ifPresent(fb -> builder.facebook(new FacebookAuth(fb.getFbId(), fb.getDisplayName()))); + authInput.getGithub() + .ifPresent(git -> builder.github(new GithubAuth(git.getGhId(), git.getDisplayName()))); + authInput.getGoogle() + .ifPresent(g -> builder.google(new GoogleAuth(g.getGoogleId(), g.getDisplayName()))); + authInput.getLinkedin() + .ifPresent(li -> builder.linkedin(new LinkedInAuth(li.getLnId(), li.getDisplayName()))); + return builder.build(); + } + + private UserProfile from(ProfileInput input) { + return new UserProfile( + input.getFirstName().orElse(""), + input.getLastName().orElse("") + ); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserQueryResolver.java b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserQueryResolver.java new file mode 100644 index 0000000000..8d9705d4ac --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserQueryResolver.java @@ -0,0 +1,34 @@ +package com.sysgears.user.resolvers; + +import com.sysgears.user.dto.UserPayload; +import com.sysgears.user.dto.input.FilterUserInput; +import com.sysgears.user.dto.input.OrderByUserInput; +import com.sysgears.user.model.User; +import com.sysgears.user.service.UserService; +import graphql.kickstart.tools.GraphQLQueryResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserQueryResolver implements GraphQLQueryResolver { + private final UserService userService; + + public CompletableFuture currentUser() { + return CompletableFuture.supplyAsync(() -> userService.getCurrentAuditor().orElse(null)); + } + + public CompletableFuture user(int id) { + return userService.findUserById(id).thenApply(UserPayload::new); + } + + public CompletableFuture> users(Optional orderBy, Optional filter) { + return userService.findByCriteria(orderBy, filter); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserSubscriptionResolver.java b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserSubscriptionResolver.java new file mode 100644 index 0000000000..c310fff02c --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/UserSubscriptionResolver.java @@ -0,0 +1,40 @@ +package com.sysgears.user.resolvers; + +import com.sysgears.core.subscription.Subscriber; +import com.sysgears.user.dto.UpdateUserPayload; +import com.sysgears.user.dto.input.FilterUserInput; +import com.sysgears.user.subscription.UserUpdatedEvent; +import graphql.kickstart.tools.GraphQLSubscriptionResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSubscriptionResolver implements GraphQLSubscriptionResolver { + private final Subscriber subscriber; + + public CompletableFuture> usersUpdated(Optional filter) { + return CompletableFuture.supplyAsync(() -> subscriber.subscribe(event -> { + if (filter.isEmpty()) return true; + + return switch (event.getMutation()) { + case DELETE_USER -> true; + case ADD_USER, EDIT_USER -> filter.get().getIsActive().map(isActive -> + event.getUser().getIsActive().equals(isActive)).orElse(true) && + filter.get().getRole().map(role -> + event.getUser().getRole().equalsIgnoreCase(role)).orElse(true) && + (filter.get().getSearchText().map(searchText -> + event.getUser().getEmail().equalsIgnoreCase(searchText)).orElse(true) || + filter.get().getSearchText().map(searchText -> + event.getUser().getUsername().equalsIgnoreCase(searchText)).orElse(true)); + default -> false; + }; + }).map(event -> new UpdateUserPayload(event.getMutation().getOperation(), event.getUser()))); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/password/LoginMutationResolver.java b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/password/LoginMutationResolver.java new file mode 100644 index 0000000000..315a5758fa --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/resolvers/password/LoginMutationResolver.java @@ -0,0 +1,156 @@ +package com.sysgears.user.resolvers.password; + +import com.sysgears.authentication.model.jwt.Tokens; +import com.sysgears.authentication.service.jwt.JwtGenerator; +import com.sysgears.authentication.service.jwt.JwtParser; +import com.sysgears.authentication.utils.SessionUtils; +import com.sysgears.mailer.service.EmailService; +import com.sysgears.service.MessageResolver; +import com.sysgears.user.config.JWTPreAuthenticationToken; +import com.sysgears.user.dto.AuthPayload; +import com.sysgears.user.dto.UserPayload; +import com.sysgears.user.dto.input.ForgotPasswordInput; +import com.sysgears.user.dto.input.LoginUserInput; +import com.sysgears.user.dto.input.RegisterUserInput; +import com.sysgears.user.dto.input.ResetPasswordInput; +import com.sysgears.user.exception.LoginFailedException; +import com.sysgears.user.exception.ResetPasswordException; +import com.sysgears.user.exception.UserAlreadyExistsException; +import com.sysgears.user.exception.UserNotFoundException; +import com.sysgears.user.model.User; +import com.sysgears.user.service.UserService; +import com.sysgears.user.util.UserIdentityUtils; +import graphql.kickstart.tools.GraphQLMutationResolver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.NoResultException; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LoginMutationResolver implements GraphQLMutationResolver { + private final UserService userService; + private final PasswordEncoder passwordEncoder; + private final JwtGenerator jwtGenerator; + private final JwtParser jwtParser; + private final EmailService emailService; + private final MessageResolver messageResolver; + + @Transactional(readOnly = true) + public CompletableFuture login(LoginUserInput loginUserInput) { + return userService.findUserByUsernameOrEmail(loginUserInput.getUsernameOrEmail()) + .thenApply(user -> { + boolean matches = passwordEncoder.matches(loginUserInput.getPassword(), user.getPassword()); + if (!matches) { + log.debug("Password is invalid"); + throw new LoginFailedException( + messageResolver.getLocalisedMessage("errors.login.failed"), + Map.of("password", messageResolver.getLocalisedMessage("errors.login.invalidPassword")) + ); + } + + Tokens tokens = jwtGenerator.generateTokens(UserIdentityUtils.convert(user)); + SessionUtils.SECURITY_CONTEXT.setAuthentication(new JWTPreAuthenticationToken(user, null)); + + return new AuthPayload(user, tokens); + }) + .exceptionally(throwable -> { + if (throwable.getCause() instanceof NoResultException) { + log.warn("Specified email or username '{}' is invalid.", loginUserInput.getUsernameOrEmail()); + throw new LoginFailedException( + messageResolver.getLocalisedMessage("errors.login.failed"), + Map.of("usernameOrEmail", messageResolver.getLocalisedMessage("errors.login.invalidUsernameOrEmail")) + ); + } else { + log.error("Unexpected error happens when find user with " + loginUserInput.getUsernameOrEmail(), throwable); + throw (CompletionException) throwable; + } + }); + } + + public CompletableFuture forgotPassword(ForgotPasswordInput input) { + if (!userService.existsByEmail(input.getEmail())) { + throw new UserNotFoundException( + messageResolver.getLocalisedMessage("errors.userNotExists"), + Map.of("email", messageResolver.getLocalisedMessage("errors.forgotPassword.noUserWithEmail")) + ); + } + + return userService.findUserByUsernameOrEmail(input.getEmail()).thenApply(user -> { + emailService.sendResetPasswordEmail( + input.getEmail(), + "/user/reset-password?key=" + jwtGenerator.generateVerificationToken(UserIdentityUtils.convert(user)) + ); + return null; + }); + } + + public CompletableFuture resetPassword(ResetPasswordInput input) { + return CompletableFuture.supplyAsync(() -> { + String token = new String(Base64.getDecoder().decode(input.getToken())); + return jwtParser.getIdFromVerificationToken(token); + }) + .thenCompose(userService::findUserById) + .thenCompose(user -> { + if (user == null) { + throw new UserNotFoundException(messageResolver.getLocalisedMessage("errors.userNotExists")); + } + if (!input.getPassword().equals(input.getPasswordConfirmation())) { + throw new ResetPasswordException( + messageResolver.getLocalisedMessage("errors.resetPassword.failed"), + Map.of("passwordConfirmation", messageResolver.getLocalisedMessage("errors.resetPassword.passwordsIsNotMatch")) + ); + } + + user.setPassword(passwordEncoder.encode(input.getPassword())); + userService.save(user); + + emailService.sendPasswordUpdatedEmail(user.getEmail()); + + return CompletableFuture.completedFuture(null); + }); + } + + public CompletableFuture register(RegisterUserInput input) { + return CompletableFuture.supplyAsync(() -> { + Map errors = new HashMap<>(); + if (userService.existsByEmail(input.getEmail())) { + errors.put("email", messageResolver.getLocalisedMessage("errors.register.emailIsExisted")); + } + if (userService.existsByUsername(input.getUsername())) { + errors.put("username", messageResolver.getLocalisedMessage("errors.register.usernameIsExisted")); + } + if (!errors.isEmpty()) { + throw new UserAlreadyExistsException( + messageResolver.getLocalisedMessage("errors.userAlreadyExists"), + errors + ); + } + + User user = new User( + input.getUsername(), + passwordEncoder.encode(input.getPassword()), + "user", + false, + input.getEmail()); + User registered = userService.save(user); + + emailService.sendRegistrationConfirmEmail( + user.getUsername(), + user.getEmail(), + "/user/confirm?key=" + jwtGenerator.generateVerificationToken(UserIdentityUtils.convert(registered)) + ); + + return new UserPayload(registered); + }); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/rest/UserController.java b/modules/user/server-java/src/main/java/com/sysgears/user/rest/UserController.java new file mode 100644 index 0000000000..4eb17552c2 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/rest/UserController.java @@ -0,0 +1,63 @@ +package com.sysgears.user.rest; + +import com.sysgears.authentication.service.jwt.JwtParser; +import com.sysgears.service.MessageResolver; +import com.sysgears.user.exception.UserNotFoundException; +import com.sysgears.user.service.UserService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.view.RedirectView; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@RestController +@RequestMapping("/user") +public class UserController { + private final UserService userService; + private final JwtParser jwtParser; + private final MessageResolver messageResolver; + private final String confirmRegistrationRedirectUrl; + private final String resetPasswordRedirectUrl; + + public UserController(UserService userService, + JwtParser jwtParser, + MessageResolver messageResolver, + @Value("${app.redirect.confirm-registration}") String confirmRegistrationRedirectUrl, + @Value("${app.redirect.reset-password}") String resetPasswordRedirectUrl) { + this.userService = userService; + this.jwtParser = jwtParser; + this.messageResolver = messageResolver; + this.confirmRegistrationRedirectUrl = confirmRegistrationRedirectUrl; + this.resetPasswordRedirectUrl = resetPasswordRedirectUrl; + } + + @GetMapping("/confirm") + public RedirectView confirmRegistration(@RequestParam String key) { + Integer userId = jwtParser.getIdFromVerificationToken(key); + return userService.findUserById(userId).thenApply(user -> { + if (user == null) throw new UserNotFoundException(messageResolver.getLocalisedMessage("errors.userNotExists")); + + user.setIsActive(true); + userService.save(user); + + return new RedirectView(confirmRegistrationRedirectUrl); + } + ).join(); + } + + @GetMapping("/reset-password") + public RedirectView initiateResetPassword(@RequestParam String key) { + Integer userId = jwtParser.getIdFromVerificationToken(key); + return userService.findUserById(userId).thenApply(user -> { + if (user == null) throw new UserNotFoundException(messageResolver.getLocalisedMessage("errors.userNotExists")); + + String base64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8)); + return new RedirectView(resetPasswordRedirectUrl + base64Key); + } + ).join(); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/service/UserService.java b/modules/user/server-java/src/main/java/com/sysgears/user/service/UserService.java new file mode 100644 index 0000000000..8ab6792d0b --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/service/UserService.java @@ -0,0 +1,115 @@ +package com.sysgears.user.service; + +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.authentication.resolvers.jwt.JwtUserIdentityService; +import com.sysgears.authentication.service.jwt.JwtParser; +import com.sysgears.authentication.utils.SessionUtils; +import com.sysgears.service.MessageResolver; +import com.sysgears.user.dto.input.FilterUserInput; +import com.sysgears.user.dto.input.OrderByUserInput; +import com.sysgears.user.exception.UserNotFoundException; +import com.sysgears.user.model.User; +import com.sysgears.user.repository.UserRepository; +import com.sysgears.user.util.UserIdentityUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.AuditorAware; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.NoResultException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class UserService implements UserDetailsService, AuditorAware, JwtUserIdentityService { + + private final UserRepository userRepository; + private final JwtParser jwtParser; + private final MessageResolver messageResolver; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return findUserByUsernameOrEmail(username).handle((user, throwable) -> { + if (throwable != null) { + if (throwable.getCause() instanceof NoResultException) { + throw new UsernameNotFoundException(messageResolver.getLocalisedMessage("errors.userWithUsernameNotExists", username)); + } + log.error(String.format("Unexpected error happened when load user by username '%s'", username), throwable); + } + return user; + }).join(); + } + + @Transactional(readOnly = true) + public CompletableFuture findUserByUsernameOrEmail(String usernameOrEmail) { + return userRepository.findByUsernameOrEmail(usernameOrEmail); + } + + @NonNull + @Override + public Optional getCurrentAuditor() { + final Authentication authentication = SessionUtils.SECURITY_CONTEXT.getAuthentication(); + + if (authentication == null) { + // no user + return Optional.empty(); + } + + if (authentication.getPrincipal() instanceof User) { + return Optional.of((User) SessionUtils.SECURITY_CONTEXT.getAuthentication().getPrincipal()); + } else { + // anonymous user + return Optional.empty(); + } + } + + @Transactional(readOnly = true) + public User loadUserByToken(String token) { + Integer userId = jwtParser.getIdFromAccessToken(token); + return userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(messageResolver.getLocalisedMessage("errors.userNotExists"))); + } + + @Transactional(readOnly = true) + public CompletableFuture> findByCriteria(Optional orderBy, Optional filter) { + return userRepository.findByCriteria(orderBy, filter); + } + + public User save(User user) { + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public CompletableFuture findUserById(Integer id) { + return userRepository.findUserById(id); + } + + public void delete(User user) { + userRepository.delete(user); + } + + @Override + public Optional findById(Integer userId) { + return userRepository.findById(userId).map(UserIdentityUtils::convert); + } + + @Transactional(readOnly = true) + public Boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } + + @Transactional(readOnly = true) + public Boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/subscription/UserPubSubService.java b/modules/user/server-java/src/main/java/com/sysgears/user/subscription/UserPubSubService.java new file mode 100644 index 0000000000..f7d8a70255 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/subscription/UserPubSubService.java @@ -0,0 +1,8 @@ +package com.sysgears.user.subscription; + +import com.sysgears.core.subscription.AbstractPubSubService; +import org.springframework.stereotype.Component; + +@Component +public class UserPubSubService extends AbstractPubSubService { +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/subscription/UserUpdatedEvent.java b/modules/user/server-java/src/main/java/com/sysgears/user/subscription/UserUpdatedEvent.java new file mode 100644 index 0000000000..13bff4044b --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/subscription/UserUpdatedEvent.java @@ -0,0 +1,24 @@ +package com.sysgears.user.subscription; + +import com.sysgears.user.model.User; +import lombok.Data; +import lombok.Getter; + +@Data +public class UserUpdatedEvent { + private final Mutation mutation; + private final User user; + + public enum Mutation { + ADD_USER("addUser"), + EDIT_USER("editUser"), + DELETE_USER("deleteUser"); + + @Getter + private final String operation; + + Mutation(String operation) { + this.operation = operation; + } + } +} diff --git a/modules/user/server-java/src/main/java/com/sysgears/user/util/UserIdentityUtils.java b/modules/user/server-java/src/main/java/com/sysgears/user/util/UserIdentityUtils.java new file mode 100644 index 0000000000..22943fd629 --- /dev/null +++ b/modules/user/server-java/src/main/java/com/sysgears/user/util/UserIdentityUtils.java @@ -0,0 +1,28 @@ +package com.sysgears.user.util; + +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.user.model.User; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class UserIdentityUtils { + + public static JwtUserIdentity convert(User user) { + JwtUserIdentity.JwtUserIdentityBuilder builder = JwtUserIdentity.builder(); + builder + .id(user.getId()) + .username(user.getUsername()) + .passwordHash(user.getPassword()) + .role(user.getRole()) + .isActive(user.getIsActive()) + .email(user.getEmail()); + + if (user.getProfile() != null) { + builder + .firstName(user.getProfile().getFirstName()) + .lastName(user.getProfile().getLastName()); + } + return builder.build(); + } +} diff --git a/modules/user/server-java/src/main/resources/password/schema.graphqls b/modules/user/server-java/src/main/resources/password/schema.graphqls new file mode 100644 index 0000000000..ee5e64726c --- /dev/null +++ b/modules/user/server-java/src/main/resources/password/schema.graphqls @@ -0,0 +1,41 @@ +input LoginUserInput { + usernameOrEmail: String! + password: String! +} + +type Tokens { + accessToken: String + refreshToken: String +} + +type AuthPayload { + user: User + tokens: Tokens +} + +input RegisterUserInput { + username: String! + email: String! + password: String! +} + +input ForgotPasswordInput { + email: String! +} + +input ResetPasswordInput { + token: String! + password: String! + passwordConfirmation: String! +} + +extend type Mutation { + # Login user + login(input: LoginUserInput!): AuthPayload! + # Forgot password + forgotPassword(input: ForgotPasswordInput!): String + # Reset password + resetPassword(input: ResetPasswordInput!): String + # Register user + register(input: RegisterUserInput!): UserPayload! +} diff --git a/modules/user/server-java/src/main/resources/schema.graphqls b/modules/user/server-java/src/main/resources/schema.graphqls new file mode 100644 index 0000000000..344aabe264 --- /dev/null +++ b/modules/user/server-java/src/main/resources/schema.graphqls @@ -0,0 +1,160 @@ +extend type Query { + # Get all users ordered by: OrderByUserInput add filtered by: FilterUserInput + users(orderBy: OrderByUserInput, filter: FilterUserInput): [User] + # Get user by id + user(id: Int!): UserPayload + # Get current user + currentUser: User +} + + +extend type Mutation { + # Create new user + addUser(input: AddUserInput!): UserPayload! + # Edit a user + editUser(input: EditUserInput!): UserPayload! + # Delete a user + deleteUser(id: Int!): UserPayload! +} + +extend type Subscription { + # Subscription for users list + usersUpdated(filter: FilterUserInput): UpdateUserPayload +} + +type User { + id: Int! + username: String! + role: String! + isActive: Boolean + email: String! + profile: UserProfile + auth: UserAuth +} + +type UserProfile { + firstName: String + lastName: String + fullName: String +} + +type UserAuth { + certificate: CertificateAuth + facebook: FacebookAuth + google: GoogleAuth + github: GithubAuth + linkedin: LinkedInAuth +} + +type CertificateAuth { + serial: String +} + +type FacebookAuth { + fbId: String + displayName: String +} + +type GoogleAuth { + googleId: String + displayName: String +} + +type GithubAuth { + ghId: String + displayName: String +} + +type LinkedInAuth { + lnId: String + displayName: String +} + +type UserPayload { + user: User +} + +# Input for ordering users +input OrderByUserInput { + # id | username | role | isActive | email + column: String + # asc | desc + order: String +} + +# Input for filtering users +input FilterUserInput { + # search by username or email + searchText: String + # filter by role + role: String + # filter by isActive + isActive: Boolean +} +# +# Additional authentication service info +input AuthInput { + certificate: AuthCertificateInput + facebook: AuthFacebookInput + google: AuthGoogleInput + github: AuthGitHubInput + linkedin: AuthLinkedInInput +} + +input AuthCertificateInput { + serial: String +} + +input AuthFacebookInput { + fbId: String + displayName: String +} + +input AuthGoogleInput { + googleId: String + displayName: String +} + +input AuthGitHubInput { + ghId: String + displayName: String +} + +input AuthLinkedInInput { + lnId: String + displayName: String +} + +# Input for addUser Mutation +input AddUserInput { + username: String! + email: String! + password: String! + role: String! + isActive: Boolean + profile: ProfileInput + auth: AuthInput +} + +# Input for editUser Mutation +input EditUserInput { + id: Int! + username: String! + role: String! + isActive: Boolean + email: String! + password: String + profile: ProfileInput + auth: AuthInput +} + +input ProfileInput { + firstName: String + lastName: String +} + +# Payload for usersUpdated Subscription +type UpdateUserPayload { + mutation: String! + node: User! +} \ No newline at end of file diff --git a/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/LoginMutationTest.java b/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/LoginMutationTest.java new file mode 100644 index 0000000000..09e509be04 --- /dev/null +++ b/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/LoginMutationTest.java @@ -0,0 +1,294 @@ +package com.sysgears.user.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.authentication.service.jwt.JwtParser; +import com.sysgears.mailer.service.EmailService; +import com.sysgears.user.dto.AuthPayload; +import com.sysgears.user.model.User; +import com.sysgears.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +public class LoginMutationTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private UserRepository userRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @MockBean + private EmailService emailService; + @SpyBean + private JwtParser jwtParser; + + private User user; + private User admin; + + @BeforeEach + void init() { + user = userRepository.findByUsernameOrEmail("user").join(); + admin = userRepository.findByUsernameOrEmail("admin").join(); + } + + @Test + void login_with_username() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("usernameOrEmail", user.getUsername()); + node.put("password", "user1234"); + + input.set("input", node); + GraphQLResponse response = template.perform("mutation/login.graphql", input); + + assertTrue(response.isOk()); + AuthPayload payload = response.get("$.data.login", AuthPayload.class); + + User userActual = payload.getUser(); + assertEquals(user.getUsername(), userActual.getUsername()); + assertEquals(user.getRole(), userActual.getRole()); + assertTrue(userActual.getIsActive()); + assertEquals(user.getEmail(), userActual.getEmail()); + assertNull(userActual.getProfile()); + + assertNotNull(payload.getTokens().getAccessToken()); + assertNotNull(payload.getTokens().getRefreshToken()); + } + + @Test + void login_with_email() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("usernameOrEmail", admin.getEmail()); + node.put("password", "admin123"); + + input.set("input", node); + GraphQLResponse response = template.perform("mutation/login.graphql", input); + + assertTrue(response.isOk()); + AuthPayload payload = response.get("$.data.login", AuthPayload.class); + + User userActual = payload.getUser(); + assertEquals(admin.getUsername(), userActual.getUsername()); + assertEquals(admin.getRole(), userActual.getRole()); + assertTrue(userActual.getIsActive()); + assertEquals(admin.getEmail(), userActual.getEmail()); + assertNull(userActual.getProfile()); + + assertNotNull(payload.getTokens().getAccessToken()); + assertNotNull(payload.getTokens().getRefreshToken()); + } + + @Test + void login_invalid_email() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("usernameOrEmail", "invalid_user"); + node.put("password", "supersecret"); + + input.set("input", node); + GraphQLResponse response = template.perform("mutation/login.graphql", input); + + assertTrue(response.isOk()); + assertEquals("Login failed.", response.get("$.errors[0].message")); + assertEquals("Please enter a valid username or e-mail.", response.get("$.errors[0].extensions.exception.errors.usernameOrEmail")); + } + + @Test + void login_invalid_password() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("usernameOrEmail", admin.getUsername()); + node.put("password", "supersecret"); + + input.set("input", node); + + GraphQLResponse response = template.perform("mutation/login.graphql", input); + + assertTrue(response.isOk()); + assertEquals("Login failed.", response.get("$.errors[0].message")); + assertEquals("Please enter a valid password.", response.get("$.errors[0].extensions.exception.errors.password")); + } + + @Test + void forgotPassword() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("email", user.getEmail()); + input.set("input", node); + + GraphQLResponse response = template.perform("/mutation/forgot-password.graphql", input); + + assertTrue(response.isOk()); + + verify(emailService).sendResetPasswordEmail(eq(user.getEmail()), startsWith("/user/reset-password?key=")); + } + + @Test + void forgotPassword_invalid_email() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("email", "user+wrong@example.com"); + input.set("input", node); + + GraphQLResponse response = template.perform("/mutation/forgot-password.graphql", input); + + assertTrue(response.isOk()); + assertEquals("User does not exist.", response.get("$.errors[0].message")); + assertEquals("No user with specified email.", response.get("$.errors[0].extensions.exception.errors.email")); + + verify(emailService, never()).sendResetPasswordEmail(anyString(), anyString()); + } + + @Test + @Transactional + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + void resetPassword() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String token = "some.verification.token"; + String password = "newpassword"; + + node.put("token", Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8))); + node.put("password", password); + node.put("passwordConfirmation", password); + input.set("input", node); + + doReturn(user.getId()).when(jwtParser).getIdFromVerificationToken(token); + + GraphQLResponse response = template.perform("/mutation/reset-password.graphql", input); + assertTrue(response.isOk()); + + User updatedUser = userRepository.getOne(user.getId()); + assertTrue(passwordEncoder.matches(password, updatedUser.getPassword())); + + verify(emailService).sendPasswordUpdatedEmail(this.user.getEmail()); + } + + @Test + void resetPassword_user_not_found() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String token = "some.verification.token"; + String password = "newpassword"; + + node.put("token", Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8))); + node.put("password", password); + node.put("passwordConfirmation", password); + input.set("input", node); + + doReturn(12313).when(jwtParser).getIdFromVerificationToken(token); + + GraphQLResponse response = template.perform("/mutation/reset-password.graphql", input); + assertTrue(response.isOk()); + assertEquals("User does not exist.", response.get("$.errors[0].message")); + + verify(emailService, never()).sendPasswordUpdatedEmail(anyString()); + } + + @Test + void resetPassword_specified_password_not_matches() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String token = "some.verification.token"; + String password = "newpassword"; + + node.put("token", Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8))); + node.put("password", password); + node.put("passwordConfirmation", password + "1"); + input.set("input", node); + + doReturn(user.getId()).when(jwtParser).getIdFromVerificationToken(token); + + GraphQLResponse response = template.perform("/mutation/reset-password.graphql", input); + assertTrue(response.isOk()); + assertEquals("Failed reset password", response.get("$.errors[0].message")); + assertEquals("Passwords do not match.", response.get("$.errors[0].extensions.exception.errors.passwordConfirmation")); + + verify(emailService, never()).sendPasswordUpdatedEmail(anyString()); + } + + @Test + void register() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + String username = "someone"; + String email = "someone@example.com"; + node.put("username", username); + node.put("email", email); + node.put("password", "supersecret"); + + input.set("input", node); + GraphQLResponse response = template.perform("/mutation/register.graphql", input); + assertTrue(response.isOk()); + + User registeredUser = response.get("$.data.register.user", User.class); + assertTrue(registeredUser.getId() != 0); + assertEquals(username, registeredUser.getUsername()); + assertEquals(email, registeredUser.getEmail()); + assertEquals("user", registeredUser.getRole()); + assertFalse(registeredUser.getIsActive()); + + verify(emailService).sendRegistrationConfirmEmail(eq(username), eq(email), startsWith("/user/confirm?key=")); + } + + @Test + void register_user_exists() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + + node.put("username", user.getUsername()); + node.put("email", user.getEmail()); + node.put("password", "supersecret"); + + input.set("input", node); + GraphQLResponse response = template.perform("/mutation/register.graphql", input); + assertTrue(response.isOk()); + assertEquals("User already exists.", response.get("$.errors[0].message")); + assertEquals("E-mail already exists.", response.get("$.errors[0].extensions.exception.errors.email")); + assertEquals("Username already exists.", response.get("$.errors[0].extensions.exception.errors.username")); + + verify(emailService, never()).sendRegistrationConfirmEmail(anyString(), anyString(), anyString()); + } +} diff --git a/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/UserMutationTest.java b/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/UserMutationTest.java new file mode 100644 index 0000000000..a631902e54 --- /dev/null +++ b/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/UserMutationTest.java @@ -0,0 +1,239 @@ +package com.sysgears.user.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.authentication.utils.SessionUtils; +import com.sysgears.user.config.JWTPreAuthenticationToken; +import com.sysgears.user.dto.UserPayload; +import com.sysgears.user.model.User; +import com.sysgears.user.model.UserAuth; +import com.sysgears.user.model.UserProfile; +import com.sysgears.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class UserMutationTest { + @Autowired + private GraphQLTestTemplate template; + @Autowired + private UserRepository userRepository; + + + @Test + void addUser() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + ObjectNode profile = mapper.createObjectNode(); + + node.put("username", "user"); + node.put("password", "supersecret"); + node.put("role", "USER"); + node.put("isActive", true); + node.put("email", "user@sysgears.com"); + + profile.put("firstName", "Edward"); + profile.put("lastName", "Fillmore"); + node.set("profile", profile); + + input.set("input", node); + GraphQLResponse response = template.perform("mutation/add-user.graphql", input); + + assertTrue(response.isOk()); + UserPayload payload = response.get("$.data.addUser", UserPayload.class); + User createdUser = payload.getUser(); + + assertEquals("user", createdUser.getUsername()); + assertEquals("USER", createdUser.getRole()); + assertTrue(createdUser.getIsActive()); + assertEquals("user@sysgears.com", createdUser.getEmail()); + assertEquals("Edward Fillmore", createdUser.getProfile().getFullName()); + assertNull(createdUser.getAuth()); + } + + @Test + void editUser() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + ObjectNode profile = mapper.createObjectNode(); + ObjectNode auth = mapper.createObjectNode(); + + node.put("id", 1); + node.put("username", "admin"); + node.put("password", "supersecret"); + node.put("role", "ADMIN"); + node.put("isActive", true); + node.put("email", "admin@sysgears.com"); + + profile.put("firstName", "John"); + profile.put("lastName", "Sinna"); + node.set("profile", profile); + + ObjectNode facebook = mapper.createObjectNode(); + facebook.put("fbId", "fb_id"); + facebook.put("displayName", "some"); + ObjectNode google = mapper.createObjectNode(); + google.put("googleId", "g_id"); + google.put("displayName", "google"); + ObjectNode github = mapper.createObjectNode(); + github.put("ghId", "gh_id"); + github.put("displayName", "github"); + ObjectNode certificate = mapper.createObjectNode(); + certificate.put("serial", "some_unique_id"); + ObjectNode linkedin = mapper.createObjectNode(); + linkedin.put("lnId", "ln_id"); + linkedin.put("displayName", "LinkedIn"); + auth.set("facebook", facebook); + auth.set("google", google); + auth.set("github", github); + auth.set("certificate", certificate); + auth.set("linkedin", linkedin); + node.set("auth", auth); + + input.set("input", node); + GraphQLResponse response = template.perform("mutation/edit-user.graphql", input); + + assertTrue(response.isOk()); + UserPayload payload = response.get("$.data.editUser", UserPayload.class); + User createdUser = payload.getUser(); + + assertEquals(1, createdUser.getId()); + assertEquals("admin", createdUser.getUsername()); + assertEquals("ADMIN", createdUser.getRole()); + assertTrue(createdUser.getIsActive()); + assertEquals("admin@sysgears.com", createdUser.getEmail()); + assertEquals("John Sinna", createdUser.getProfile().getFullName()); + + UserAuth userAuth = createdUser.getAuth(); + assertNotNull(userAuth); + assertEquals("some_unique_id", userAuth.getCertificate().getSerial()); + assertEquals("fb_id", userAuth.getFacebook().getFbId()); + assertEquals("some", userAuth.getFacebook().getDisplayName()); + assertEquals("g_id", userAuth.getGoogle().getGoogleId()); + assertEquals("google", userAuth.getGoogle().getDisplayName()); + assertEquals("gh_id", userAuth.getGithub().getGhId()); + assertEquals("github", userAuth.getGithub().getDisplayName()); + assertEquals("ln_id", userAuth.getLinkedin().getLnId()); + assertEquals("LinkedIn", userAuth.getLinkedin().getDisplayName()); + } + + @Test + void editUser_with_already_saved_profile_data() throws IOException { + final User user = userRepository.findById(1).get(); + user.setProfile(new UserProfile("James", "Abrams")); + userRepository.save(user); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode input = mapper.createObjectNode(); + ObjectNode node = mapper.createObjectNode(); + ObjectNode profile = mapper.createObjectNode(); + + node.put("id", 1); + node.put("username", "admin"); + node.put("role", "ADMIN"); + node.put("isActive", true); + node.put("email", "admin@sysgears.com"); + + profile.put("firstName", "John"); + profile.put("lastName", "Sinna"); + node.set("profile", profile); + + input.set("input", node); + GraphQLResponse response = template.perform("mutation/edit-user.graphql", input); + + assertTrue(response.isOk()); + UserPayload payload = response.get("$.data.editUser", UserPayload.class); + User createdUser = payload.getUser(); + + assertEquals(1, createdUser.getId()); + assertEquals("admin", createdUser.getUsername()); + assertEquals("ADMIN", createdUser.getRole()); + assertTrue(createdUser.getIsActive()); + assertEquals("admin@sysgears.com", createdUser.getEmail()); + assertEquals("John Sinna", createdUser.getProfile().getFullName()); + } + + @Test + void deleteUser() throws IOException { + User admin = userRepository.findByUsernameOrEmail("admin").join(); + SessionUtils.SECURITY_CONTEXT.setAuthentication(new JWTPreAuthenticationToken(admin, null)); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 2); + + GraphQLResponse response = template.perform("mutation/delete-user.graphql", node); + + UserPayload payload = response.get("$.data.deleteUser", UserPayload.class); + User deletedUser = payload.getUser(); + + assertEquals(2, deletedUser.getId()); + } + + @Test + void deleteUser_not_exists() throws IOException { + userRepository.deleteById(2); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 2); + + GraphQLResponse response = template.perform("mutation/delete-user.graphql", node); + + assertEquals("User with id 2 does not exist.", response.get("$.errors[0].message")); + } + + @Test + void deleteUser_admin_not_login() throws IOException { + SessionUtils.SECURITY_CONTEXT.setAuthentication(null); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 2); + + GraphQLResponse response = template.perform("mutation/delete-user.graphql", node); + + assertEquals("You have not enough permissions to delete users.", response.get("$.errors[0].message")); + } + + @Test + void deleteUser_not_enough_permission() throws IOException { + User user = userRepository.findByUsernameOrEmail("user").join(); + SessionUtils.SECURITY_CONTEXT.setAuthentication(new JWTPreAuthenticationToken(user, null)); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 1); + + GraphQLResponse response = template.perform("mutation/delete-user.graphql", node); + + assertEquals("You have not enough permissions to delete users.", response.get("$.errors[0].message")); + } + + @Test + void deleteUser_try_delete_yourself() throws IOException { + User admin = userRepository.findByUsernameOrEmail("admin").join(); + SessionUtils.SECURITY_CONTEXT.setAuthentication(new JWTPreAuthenticationToken(admin, null)); + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 1); + + GraphQLResponse response = template.perform("mutation/delete-user.graphql", node); + + assertEquals("You can not delete your self.", response.get("$.errors[0].message")); + } +} diff --git a/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/UserQueryTest.java b/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/UserQueryTest.java new file mode 100644 index 0000000000..2f3e2e967f --- /dev/null +++ b/modules/user/server-java/src/test/java/com/sysgears/user/resolvers/UserQueryTest.java @@ -0,0 +1,155 @@ +package com.sysgears.user.resolvers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.graphql.spring.boot.test.GraphQLResponse; +import com.graphql.spring.boot.test.GraphQLTestTemplate; +import com.sysgears.authentication.utils.SessionUtils; +import com.sysgears.user.config.JWTPreAuthenticationToken; +import com.sysgears.user.dto.UserPayload; +import com.sysgears.user.model.User; +import com.sysgears.user.model.UserAuth; +import com.sysgears.user.model.UserProfile; +import com.sysgears.user.model.auth.*; +import com.sysgears.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Transactional +public class UserQueryTest { + @Autowired + private GraphQLTestTemplate template; + @MockBean + private UserRepository repository; + + private User user; + private final CertificateAuth certificateAuth = new CertificateAuth(UUID.randomUUID().toString()); + private final FacebookAuth facebookAuth = new FacebookAuth(UUID.randomUUID().toString(), "facebookAuthName"); + private final GithubAuth githubAuth = new GithubAuth(UUID.randomUUID().toString(), "githubAuthName"); + private final GoogleAuth googleAuth = new GoogleAuth(UUID.randomUUID().toString(), "googleAuthName"); + private final LinkedInAuth linkedInAuth = new LinkedInAuth(UUID.randomUUID().toString(), "linkedInAuthName"); + + @BeforeEach + void init() { + SessionUtils.SECURITY_CONTEXT.setAuthentication(null); + user = new User("admin", "pass", "ADMIN", true, "example@sysgears.com"); + user.setProfile(new UserProfile("John", "Smith")); + user.setAuth( + UserAuth.builder() + .certificate(certificateAuth) + .facebook(facebookAuth) + .github(githubAuth) + .google(googleAuth) + .linkedin(linkedInAuth) + .build() + ); + } + + @Test + void users() throws IOException { + when(repository.findByCriteria(Optional.empty(), Optional.empty())) + .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(user))); + + GraphQLResponse response = template.postForResource("query/users.graphql"); + + assertTrue(response.isOk()); + User actualUser = response.get("$.data.users[0]", User.class); + assertThat(actualUser) + .hasFieldOrPropertyWithValue("username", "admin") + .hasFieldOrPropertyWithValue("role", "ADMIN") + .hasFieldOrPropertyWithValue("email", "example@sysgears.com") + .hasFieldOrPropertyWithValue("isActive", true) + .hasFieldOrPropertyWithValue("profile.firstName", "John") + .hasFieldOrPropertyWithValue("profile.lastName", "Smith") + .hasFieldOrPropertyWithValue("profile.fullName", "John Smith"); + assertNotNull(actualUser.getAuth()); + assertEquals(certificateAuth, actualUser.getAuth().getCertificate()); + assertEquals(facebookAuth, actualUser.getAuth().getFacebook()); + assertEquals(githubAuth, actualUser.getAuth().getGithub()); + assertEquals(googleAuth, actualUser.getAuth().getGoogle()); + assertEquals(linkedInAuth, actualUser.getAuth().getLinkedin()); + } + + @Test + void user() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 1); + + when(repository.findUserById(1)) + .thenReturn(CompletableFuture.completedFuture(user)); + + GraphQLResponse response = template.perform("query/user.graphql", node); + + assertTrue(response.isOk()); + UserPayload payload = response.get("$.data.user", UserPayload.class); + User actualUser = payload.getUser(); + assertThat(actualUser) + .hasFieldOrPropertyWithValue("username", "admin") + .hasFieldOrPropertyWithValue("role", "ADMIN") + .hasFieldOrPropertyWithValue("email", "example@sysgears.com") + .hasFieldOrPropertyWithValue("isActive", true) + .hasFieldOrPropertyWithValue("profile.firstName", "John") + .hasFieldOrPropertyWithValue("profile.lastName", "Smith") + .hasFieldOrPropertyWithValue("profile.fullName", "John Smith"); + assertNotNull(actualUser.getAuth()); + assertEquals(certificateAuth, actualUser.getAuth().getCertificate()); + } + + @Test + void user_not_found() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + node.put("id", 115); + + when(repository.findUserById(115)) + .thenReturn(CompletableFuture.completedFuture(null)); + + GraphQLResponse response = template.perform("query/user.graphql", node); + + assertTrue(response.isOk()); + UserPayload payload = response.get("$.data.user", UserPayload.class); + assertNull(payload.getUser()); + } + + @Test + void currentUser() throws IOException { + SessionUtils.SECURITY_CONTEXT.setAuthentication(new JWTPreAuthenticationToken(user, null)); + + GraphQLResponse response = template.postForResource("query/current-user.graphql"); + + assertTrue(response.isOk()); + User actualUser = response.get("$.data.currentUser", User.class); + assertThat(actualUser) + .hasFieldOrPropertyWithValue("username", "admin") + .hasFieldOrPropertyWithValue("profile.firstName", "John") + .hasFieldOrPropertyWithValue("profile.lastName", "Smith") + .hasFieldOrPropertyWithValue("profile.fullName", "John Smith"); + assertNotNull(actualUser.getAuth()); + assertEquals(facebookAuth, actualUser.getAuth().getFacebook()); + } + + @Test + void currentUser_not_login_yet() throws IOException { + GraphQLResponse response = template.postForResource("query/current-user.graphql"); + + assertTrue(response.isOk()); + User actualUser = response.get("$.data.currentUser", User.class); + assertNull(actualUser); + } +} diff --git a/modules/user/server-java/src/test/java/com/sysgears/user/rest/UserControllerTest.java b/modules/user/server-java/src/test/java/com/sysgears/user/rest/UserControllerTest.java new file mode 100644 index 0000000000..e6633d3f4b --- /dev/null +++ b/modules/user/server-java/src/test/java/com/sysgears/user/rest/UserControllerTest.java @@ -0,0 +1,99 @@ +package com.sysgears.user.rest; + +import com.sysgears.authentication.model.jwt.JwtUserIdentity; +import com.sysgears.authentication.service.jwt.JwtGenerator; +import com.sysgears.user.model.User; +import com.sysgears.user.service.UserService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class UserControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private JwtGenerator jwtGenerator; + @MockBean + private UserService userService; + @Value("${app.redirect.confirm-registration}") + private String confirmRegistrationRedirectUrl; + @Value("${app.redirect.reset-password}") + private String resetPasswordRedirectUrl; + + @Test + void confirmRegistration() throws Exception { + User registeredUser = new User("username", "password", "user", false, "someone@example.com"); + registeredUser.setId(33); + String token = jwtGenerator.generateVerificationToken(JwtUserIdentity.builder().id(registeredUser.getId()).build()); + + when(userService.findUserById(registeredUser.getId())).thenReturn(CompletableFuture.completedFuture(registeredUser)); + + mockMvc.perform(get("/user/confirm").param("key", token)) + .andDo(print()) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(confirmRegistrationRedirectUrl)); + + ArgumentCaptor userArgumentCaptor = ArgumentCaptor.forClass(User.class); + verify(userService).save(userArgumentCaptor.capture()); + assertTrue(userArgumentCaptor.getValue().getIsActive()); + } + + @Test + void confirmRegistration_no_user_found() throws Exception { + int invalidUserId = 543; + String token = jwtGenerator.generateVerificationToken(JwtUserIdentity.builder().id(invalidUserId).build()); + + when(userService.findUserById(invalidUserId)).thenReturn(CompletableFuture.completedFuture(null)); + + mockMvc.perform(get("/user/confirm").param("key", token)) + .andDo(print()) + .andExpect(status().isNotFound()); + + verify(userService, never()).save(any(User.class)); + } + + @Test + void initiateResetPassword() throws Exception { + User user = new User("username", "password", "user", false, "someone@example.com"); + user.setId(33); + String token = jwtGenerator.generateVerificationToken(JwtUserIdentity.builder().id(user.getId()).build()); + String expectedRedirectedUrl = resetPasswordRedirectUrl + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); + + when(userService.findUserById(user.getId())).thenReturn(CompletableFuture.completedFuture(user)); + + mockMvc.perform(get("/user/reset-password").param("key", token)) + .andDo(print()) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl(expectedRedirectedUrl)); + } + + @Test + void initiateResetPassword_no_user_found() throws Exception { + int invalidUserId = 1234; + String token = jwtGenerator.generateVerificationToken(JwtUserIdentity.builder().id(invalidUserId).build()); + + when(userService.findUserById(invalidUserId)).thenReturn(CompletableFuture.completedFuture(null)); + + mockMvc.perform(get("/user/reset-password").param("key", token)) + .andDo(print()) + .andExpect(status().isNotFound()); + } +} diff --git a/modules/user/server-java/src/test/resources/mutation/add-user.graphql b/modules/user/server-java/src/test/resources/mutation/add-user.graphql new file mode 100644 index 0000000000..60eecc623d --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/add-user.graphql @@ -0,0 +1,24 @@ +mutation AddUser($input: AddUserInput!) { + addUser(input: $input) { + user { + id + username + role + isActive + email + profile{ + fullName + } + auth { + google { + googleId + displayName + } + facebook { + fbId + displayName + } + } + } + } +} \ No newline at end of file diff --git a/modules/user/server-java/src/test/resources/mutation/delete-user.graphql b/modules/user/server-java/src/test/resources/mutation/delete-user.graphql new file mode 100644 index 0000000000..7ec41ad55b --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/delete-user.graphql @@ -0,0 +1,35 @@ +mutation DeleteUser($id: Int!) { + deleteUser(id: $id) { + user { + id + username + role + isActive + email + profile{ + fullName + } + auth { + google { + googleId + displayName + } + facebook { + fbId + displayName + } + github { + ghId + displayName + } + certificate { + serial + } + linkedin { + lnId + displayName + } + } + } + } +} \ No newline at end of file diff --git a/modules/user/server-java/src/test/resources/mutation/edit-user.graphql b/modules/user/server-java/src/test/resources/mutation/edit-user.graphql new file mode 100644 index 0000000000..7f1047f39f --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/edit-user.graphql @@ -0,0 +1,35 @@ +mutation EditUser($input: EditUserInput!) { + editUser(input: $input) { + user { + id + username + role + isActive + email + profile{ + fullName + } + auth { + google { + googleId + displayName + } + facebook { + fbId + displayName + } + github { + ghId + displayName + } + certificate { + serial + } + linkedin { + lnId + displayName + } + } + } + } +} \ No newline at end of file diff --git a/modules/user/server-java/src/test/resources/mutation/forgot-password.graphql b/modules/user/server-java/src/test/resources/mutation/forgot-password.graphql new file mode 100644 index 0000000000..e31316773f --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/forgot-password.graphql @@ -0,0 +1,3 @@ +mutation forgotPassword($input: ForgotPasswordInput!) { + forgotPassword(input: $input) +} diff --git a/modules/user/server-java/src/test/resources/mutation/login.graphql b/modules/user/server-java/src/test/resources/mutation/login.graphql new file mode 100644 index 0000000000..f9f8d53bff --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/login.graphql @@ -0,0 +1,18 @@ +mutation login($input: LoginUserInput!) { + login(input: $input) { + user { + id + username + role + isActive + email + profile { + fullName + } + } + tokens { + accessToken + refreshToken + } + } +} diff --git a/modules/user/server-java/src/test/resources/mutation/register.graphql b/modules/user/server-java/src/test/resources/mutation/register.graphql new file mode 100644 index 0000000000..af72f5ebaf --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/register.graphql @@ -0,0 +1,11 @@ +mutation register($input: RegisterUserInput!) { + register(input: $input) { + user { + id + username + role + isActive + email + } + } +} diff --git a/modules/user/server-java/src/test/resources/mutation/reset-password.graphql b/modules/user/server-java/src/test/resources/mutation/reset-password.graphql new file mode 100644 index 0000000000..75d40798eb --- /dev/null +++ b/modules/user/server-java/src/test/resources/mutation/reset-password.graphql @@ -0,0 +1,3 @@ +mutation resetPassword($input: ResetPasswordInput!) { + resetPassword(input: $input) +} diff --git a/modules/user/server-java/src/test/resources/query/current-user.graphql b/modules/user/server-java/src/test/resources/query/current-user.graphql new file mode 100644 index 0000000000..6d10a906b8 --- /dev/null +++ b/modules/user/server-java/src/test/resources/query/current-user.graphql @@ -0,0 +1,16 @@ +query { + currentUser { + username + profile { + firstName + lastName + fullName + } + auth { + facebook { + displayName + fbId + } + } + } +} \ No newline at end of file diff --git a/modules/user/server-java/src/test/resources/query/user.graphql b/modules/user/server-java/src/test/resources/query/user.graphql new file mode 100644 index 0000000000..eab0dd300a --- /dev/null +++ b/modules/user/server-java/src/test/resources/query/user.graphql @@ -0,0 +1,21 @@ +query GetUser($id: Int!){ + user(id: $id) { + user { + id + username + role + isActive + email + profile { + firstName + lastName + fullName + } + auth{ + certificate { + serial + } + } + } + } +} \ No newline at end of file diff --git a/modules/user/server-java/src/test/resources/query/users.graphql b/modules/user/server-java/src/test/resources/query/users.graphql new file mode 100644 index 0000000000..296cb2ff3d --- /dev/null +++ b/modules/user/server-java/src/test/resources/query/users.graphql @@ -0,0 +1,35 @@ +query { + users { + id + username + role + isActive + email + profile { + firstName + lastName + fullName + } + auth { + google { + googleId + displayName + } + facebook { + fbId + displayName + } + github { + ghId + displayName + } + certificate { + serial + } + linkedin { + lnId + displayName + } + } + } +} \ No newline at end of file diff --git a/packages/server-java/.gitignore b/packages/server-java/.gitignore new file mode 100644 index 0000000000..62b39ddbad --- /dev/null +++ b/packages/server-java/.gitignore @@ -0,0 +1,13 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/** +!**/src/test/** + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +/target \ No newline at end of file diff --git a/packages/server-java/app/build.gradle b/packages/server-java/app/build.gradle new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/server-java/app/src/main/java/com/sysgears/Application.java b/packages/server-java/app/src/main/java/com/sysgears/Application.java new file mode 100644 index 0000000000..566eaeb0e2 --- /dev/null +++ b/packages/server-java/app/src/main/java/com/sysgears/Application.java @@ -0,0 +1,13 @@ +package com.sysgears; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync +@SpringBootApplication(scanBasePackages = {"com.sysgears"}) +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/packages/server-java/app/src/main/resources/application.yml b/packages/server-java/app/src/main/resources/application.yml new file mode 100644 index 0000000000..d37b8b0697 --- /dev/null +++ b/packages/server-java/app/src/main/resources/application.yml @@ -0,0 +1,72 @@ +spring: + jpa: + hibernate: + ddl-auto: update + open-in-view: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + show-sql: true + datasource: + driver-class-name: org.h2.Driver + password: '' + url: jdbc:h2:mem:apollo + username: sa + h2: + console: + enabled: true + path: /h2-console +# Fill real host, username and password for working with mailer module + mail: + host: EMAIL_HOST + port: 587 + username: EMAIL_USER + password: EMAIL_PASSWORD + properties: + mail: + smtp: + auth: true + starttls: + enable: true + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + messages: + basename: i18n/messages +graphql: + servlet: + exception-handlers-enabled: true + cors: + allowed-origins: "*" + allow-credentials: true + subscriptions: + apollo: + keep-alive-enabled: false + websocket: + path: /graphql +graphiql: + enabled: true + endpoint: + subscriptions: /graphql + mapping: /graphiql + headers: + Authorization: Bearer generated-token + props: + variables: + headerEditorEnabled: true + +jwt: + secret: "676918AB4D29BFE59CCB943F3C09F5CC8FB3A8511E23E502B67DE95AB9A9D00C" + access-token-expiration-in-sec: 3600 + refresh-token-expiration-in-sec: 604800 + +app: + server: + baseUrl: "localhost:8080" + client: + baseUrl: "http://localhost:3000" + redirect: + confirm-registration: "${app.client.baseUrl}/login/?email-verified" + reset-password: "${app.client.baseUrl}/reset-password/" + profile: "${app.client.baseUrl}/profile" diff --git a/packages/server-java/app/src/main/resources/i18n/messages.properties b/packages/server-java/app/src/main/resources/i18n/messages.properties new file mode 100644 index 0000000000..bd58210624 --- /dev/null +++ b/packages/server-java/app/src/main/resources/i18n/messages.properties @@ -0,0 +1,15 @@ +# User module +errors.login.failed=Login failed. +errors.login.invalidPassword=Please enter a valid password. +errors.login.invalidUsernameOrEmail=Please enter a valid username or e-mail. +errors.forgotPassword.noUserWithEmail=No user with specified email. +errors.resetPassword.failed=Failed reset password +errors.resetPassword.passwordsIsNotMatch=Passwords do not match. +errors.register.emailIsExisted=E-mail already exists. +errors.register.usernameIsExisted=Username already exists. +errors.userNotExists=User does not exist. +errors.userWithIdNotExists=User with id {0} does not exist. +errors.userWithUsernameNotExists=User with username {0} does not exist. +errors.userAlreadyExists=User already exists. +errors.deleteUser.cannotDeleteYourself=You can not delete your self. +errors.deleteUser.notEnoughPermission=You have not enough permissions to delete users. diff --git a/packages/server-java/app/src/main/resources/i18n/messages_ru.properties b/packages/server-java/app/src/main/resources/i18n/messages_ru.properties new file mode 100644 index 0000000000..fe624e7fd7 --- /dev/null +++ b/packages/server-java/app/src/main/resources/i18n/messages_ru.properties @@ -0,0 +1,15 @@ +# User module +errors.login.failed=Не удалось войти. +errors.login.invalidPassword=Пожалуйста, введите действительный пароль. +errors.login.invalidUsernameOrEmail=Пожалуйста, введите Ваш username или e-mail. +errors.forgotPassword.noUserWithEmail=Пользователя с таким e-mail не существует. +errors.resetPassword.failed=Не удалось сбросить пароль +errors.resetPassword.passwordsIsNotMatch=Пароли не совпадают. +errors.register.emailIsExisted=Данный e-mail уже используется. +errors.register.usernameIsExisted=Данное имя пользователя уже используется. +errors.userNotExists=Такого пользователя не существует. +errors.userWithIdNotExists=Пользователя с идентификатором {0} не существует. +errors.userWithUsernameNotExists=Пользователя с именем {0} не существует. +errors.userAlreadyExists=Такой пользователь уже существует. +errors.deleteUser.cannotDeleteYourself=Вы не можете удалить себя. +errors.deleteUser.notEnoughPermission=У вас недостаточно прав для удаления пользоваетелей. diff --git a/packages/server-java/app/src/main/resources/templates/confirm-registration.html b/packages/server-java/app/src/main/resources/templates/confirm-registration.html new file mode 100644 index 0000000000..4a92888268 --- /dev/null +++ b/packages/server-java/app/src/main/resources/templates/confirm-registration.html @@ -0,0 +1,15 @@ + + + + Confirm Email + + + +

+ +

Welcome to Apollo Universal Starter Kit.

+

Please click the following link to confirm your email: + +

+ + diff --git a/packages/server-java/app/src/main/resources/templates/contact-us.html b/packages/server-java/app/src/main/resources/templates/contact-us.html new file mode 100644 index 0000000000..0b5a493dd7 --- /dev/null +++ b/packages/server-java/app/src/main/resources/templates/contact-us.html @@ -0,0 +1,11 @@ + + + + Reset Password + + + +

+

+ + diff --git a/packages/server-java/app/src/main/resources/templates/password-updated.html b/packages/server-java/app/src/main/resources/templates/password-updated.html new file mode 100644 index 0000000000..d1a3ba9da9 --- /dev/null +++ b/packages/server-java/app/src/main/resources/templates/password-updated.html @@ -0,0 +1,12 @@ + + + + Password Has Been Updated + + + +

As you requested, your account password has been updated.

+

To view or edit your account settings, please visit the “Profile” page at

+

+ + diff --git a/packages/server-java/app/src/main/resources/templates/reset-password.html b/packages/server-java/app/src/main/resources/templates/reset-password.html new file mode 100644 index 0000000000..2be70a272b --- /dev/null +++ b/packages/server-java/app/src/main/resources/templates/reset-password.html @@ -0,0 +1,13 @@ + + + + Reset Password + + + +

Please click this link to reset your password:

+

+ +

+ + diff --git a/packages/server-java/build.gradle b/packages/server-java/build.gradle new file mode 100644 index 0000000000..4d87df4fb1 --- /dev/null +++ b/packages/server-java/build.gradle @@ -0,0 +1,94 @@ +buildscript { + ext { + springBootVersion = '2.3.7.RELEASE' + } + repositories { + maven { url "https://plugins.gradle.org/m2/" } + } + dependencies { + classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") + } +} + +subprojects { + apply plugin: 'idea' + apply plugin: 'java' + apply plugin: 'java-library' + apply plugin: 'io.spring.dependency-management' + apply plugin: 'org.springframework.boot' + + group = 'com.sysgears' + version = 'release' + + sourceCompatibility = 14 + + jar { + enabled = true + } + bootJar { + enabled = false + } + repositories { + mavenCentral() + jcenter() + maven { url "https://repo.spring.io/milestone" } + maven { url "https://plugins.gradle.org/m2/" } + } + + dependencies { + //Spring Boot + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-aop' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + //Lombok + compileOnly "org.projectlombok:lombok:1.18.12" + annotationProcessor "org.projectlombok:lombok:1.18.12" + + //H2 DataBase + implementation 'com.h2database:h2:1.4.200' + + //GraphQL + implementation 'com.graphql-java-kickstart:graphql-spring-boot-starter:8.1.1' + runtimeOnly 'com.graphql-java-kickstart:graphiql-spring-boot-starter:8.1.1' + implementation 'com.graphql-java-kickstart:graphql-java-tools:6.3.0' + testImplementation 'com.graphql-java-kickstart:graphql-spring-boot-starter-test:8.1.1' + + implementation "io.reactivex.rxjava2:rxjava" + + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + testImplementation project(':app') + } + + test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + } + outputs.upToDateWhen { false } + } +} + +project(':app') { + dependencies { + implementation project(':counter') + implementation project(':user') + implementation project(':authentication') + implementation project(':mailer') + implementation project(':contact') + implementation project(':upload') + implementation project(':post') + implementation project(':chat') + implementation project(':reports') + implementation project(':i18n') + } + bootJar { + enabled = true + launchScript() + mainClassName = 'com.sysgears.Application' + } +} diff --git a/packages/server-java/gradle.properties b/packages/server-java/gradle.properties new file mode 100644 index 0000000000..4334c11fef --- /dev/null +++ b/packages/server-java/gradle.properties @@ -0,0 +1 @@ +kotlin.version=1.3.70 \ No newline at end of file diff --git a/packages/server-java/gradle/wrapper/gradle-wrapper.jar b/packages/server-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..5c2d1cf016 Binary files /dev/null and b/packages/server-java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/server-java/gradle/wrapper/gradle-wrapper.properties b/packages/server-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..d18ead82e0 --- /dev/null +++ b/packages/server-java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Jul 23 14:04:29 EEST 2020 +distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/packages/server-java/gradlew b/packages/server-java/gradlew new file mode 100755 index 0000000000..9a8978e97d --- /dev/null +++ b/packages/server-java/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java dto to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' dto could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a balance-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java dto, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/packages/server-java/gradlew.bat b/packages/server-java/gradlew.bat new file mode 100644 index 0000000000..9618d8d960 --- /dev/null +++ b/packages/server-java/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/server-java/settings.gradle b/packages/server-java/settings.gradle new file mode 100644 index 0000000000..efb6a031ad --- /dev/null +++ b/packages/server-java/settings.gradle @@ -0,0 +1,14 @@ +rootProject.name = 'server-java' +include ':app', ':core', ':counter', ':user', ':authentication', 'mailer', 'contact', 'upload', 'post', 'chat', 'reports', 'i18n' + +project(':core').projectDir = new File('../../modules/core/server-java') +project(':counter').projectDir = new File('../../modules/counter/server-java') +project(':user').projectDir = new File('../../modules/user/server-java') +project(':authentication').projectDir = new File('../../modules/authentication/server-java') +project(':mailer').projectDir = new File('../../modules/mailer/server-java') +project(':contact').projectDir = new File('../../modules/contact/server-java') +project(':upload').projectDir = new File('../../modules/upload/server-java') +project(':post').projectDir = new File('../../modules/post/server-java') +project(':chat').projectDir = new File('../../modules/chat/server-java') +project(':reports').projectDir = new File('../../modules/reports/server-java') +project(':i18n').projectDir = new File('../../modules/i18n/server-java')