diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index cf397d62..46d234d5 100755 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -22,4 +22,4 @@ echo "printf \"\n=============================================\n\"" >> ~/.bashrc echo "gh codespace ports -c $CODESPACE_NAME" >> ~/.bashrc echo "printf \"=============================================\n\"" >> ~/.bashrc echo "printf \"(Once docker-compose is up and running, you can access the frontend and backend using the above urls)\n\"" >> ~/.bashrc -echo "printf \"\n\x1b[31m \x1b[1mπŸ‘‰ Type: \\\`docker-compose up\\\` to run the project. πŸ‘ˆ\n\n\"" >> ~/.bashrc +echo "printf \"\n\x1b[31m \x1b[1mπŸ‘‰ Type: \\\`docker compose up\\\` to run the project. πŸ‘ˆ\n\n\"" >> ~/.bashrc diff --git a/.framework/java/backend/.gitignore b/.framework/java/backend/.gitignore new file mode 100644 index 00000000..dfcbb128 --- /dev/null +++ b/.framework/java/backend/.gitignore @@ -0,0 +1,26 @@ +.gradle/ +/build/ +!gradle/wrapper/gradle-wrapper.jar +*.db + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ diff --git a/.framework/java/backend/Dockerfile b/.framework/java/backend/Dockerfile new file mode 100644 index 00000000..30bdf809 --- /dev/null +++ b/.framework/java/backend/Dockerfile @@ -0,0 +1,2 @@ +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-backend-java:latest + diff --git a/.framework/java/backend/LICENSE b/.framework/java/backend/LICENSE new file mode 100644 index 00000000..a6fd2b77 --- /dev/null +++ b/.framework/java/backend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Aisensiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.framework/java/backend/README.md b/.framework/java/backend/README.md new file mode 100644 index 00000000..acbddba5 --- /dev/null +++ b/.framework/java/backend/README.md @@ -0,0 +1,35 @@ +# Anythink Market Backend + +# How it works + +The application uses Spring Boot (Web, Mybatis). + +And the code is organized as this: + +1. `api` is the web layer implemented by Spring MVC +2. `core` is the business model including entities and services +3. `application` is the high-level services for querying the data transfer objects +4. `infrastructure` contains all the implementation classes as the technique details + +# Getting started + +You'll need Java 11 installed. + + ./gradlew bootRun + +To test that it works, open a browser tab at http://localhost:3000/api/tags +Alternatively, you can run: + + curl http://localhost:3000/api/tags + +# Run test + +The repository contains a lot of test cases to cover both api test and repository test. + + ./gradlew test + +# Code format + +Use spotless for code format. + + ./gradlew spotlessJavaApply diff --git a/.framework/java/backend/build.gradle b/.framework/java/backend/build.gradle new file mode 100644 index 00000000..decaba7a --- /dev/null +++ b/.framework/java/backend/build.gradle @@ -0,0 +1,73 @@ +plugins { + id 'org.springframework.boot' version '2.6.3' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'java' + id "com.netflix.dgs.codegen" version "5.0.6" + id "com.diffplug.spotless" version "6.2.1" +} + +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' +targetCompatibility = '11' + +spotless { + java { + target project.fileTree(project.rootDir) { + include '**/*.java' + exclude 'build/generated/**/*.*', 'build/generated-examples/**/*.*' + } + googleJavaFormat() + } +} + +repositories { + mavenCentral() +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-hateoas' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2' + implementation 'com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter:4.9.21' + implementation 'org.flywaydb:flyway-core' + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2', + 'io.jsonwebtoken:jjwt-jackson:0.11.2' + implementation 'joda-time:joda-time:2.10.13' + implementation 'org.xerial:sqlite-jdbc:3.36.0.3' + implementation 'org.postgresql:postgresql:42.2.24' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'io.rest-assured:rest-assured:4.5.1' + testImplementation 'io.rest-assured:json-path:4.5.1' + testImplementation 'io.rest-assured:xml-path:4.5.1' + testImplementation 'io.rest-assured:spring-mock-mvc:4.5.1' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.2.2' +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.named('clean') { + doFirst { + delete './dev.db' + } +} + +tasks.named('generateJava') { + schemaPaths = ["${projectDir}/src/main/resources/schema"] // List of directories containing schema files + packageName = 'io.spring.graphql' // The package name to use to generate sources +} diff --git a/.framework/java/backend/gradle/wrapper/gradle-wrapper.jar b/.framework/java/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/.framework/java/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/.framework/java/backend/gradle/wrapper/gradle-wrapper.properties b/.framework/java/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..41dfb879 --- /dev/null +++ b/.framework/java/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/.framework/java/backend/gradlew b/.framework/java/backend/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/.framework/java/backend/gradlew @@ -0,0 +1,185 @@ +#!/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 command 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' command 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 user-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=`expr $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 command, 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" + +exec "$JAVACMD" "$@" diff --git a/.framework/java/backend/gradlew.bat b/.framework/java/backend/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/.framework/java/backend/gradlew.bat @@ -0,0 +1,89 @@ +@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 Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@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 execute + +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 execute + +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 + +: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 %* + +: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/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java b/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java new file mode 100644 index 00000000..8469390c --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java @@ -0,0 +1,12 @@ +package io.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AnythinkMarketApplication { + + public static void main(String[] args) { + SpringApplication.run(AnythinkMarketApplication.class, args); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java b/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java new file mode 100644 index 00000000..874a46ee --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java @@ -0,0 +1,44 @@ +package io.spring; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; +import org.joda.time.DateTime; +import org.joda.time.format.ISODateTimeFormat; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonCustomizations { + + @Bean + public Module anythinkMarkerModules() { + return new AnythinkMarketModules(); + } + + public static class AnythinkMarketModules extends SimpleModule { + public AnythinkMarketModules() { + addSerializer(DateTime.class, new DateTimeSerializer()); + } + } + + public static class DateTimeSerializer extends StdSerializer { + + protected DateTimeSerializer() { + super(DateTime.class); + } + + @Override + public void serialize(DateTime value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value == null) { + gen.writeNull(); + } else { + gen.writeString(ISODateTimeFormat.dateTime().withZoneUTC().print(value)); + } + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/MyBatisConfig.java b/.framework/java/backend/src/main/java/io/spring/MyBatisConfig.java new file mode 100644 index 00000000..d1f741cf --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/MyBatisConfig.java @@ -0,0 +1,8 @@ +package io.spring; + +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Configuration +@EnableTransactionManagement +public class MyBatisConfig {} diff --git a/.framework/java/backend/src/main/java/io/spring/Util.java b/.framework/java/backend/src/main/java/io/spring/Util.java new file mode 100644 index 00000000..d2512acc --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/Util.java @@ -0,0 +1,7 @@ +package io.spring; + +public class Util { + public static boolean isEmpty(String value) { + return value == null || value.isEmpty(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java b/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java new file mode 100644 index 00000000..b4bc6b92 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java @@ -0,0 +1,99 @@ +package io.spring.api; + +import com.fasterxml.jackson.annotation.JsonRootName; +import io.spring.api.exception.NoAuthorizationException; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.CommentQueryService; +import io.spring.application.data.CommentData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.service.AuthorizationService; +import io.spring.core.user.User; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/items/{slug}/comments") +@AllArgsConstructor +public class CommentsApi { + private ItemRepository itemRepository; + private CommentRepository commentRepository; + private CommentQueryService commentQueryService; + + @PostMapping + public ResponseEntity createComment( + @PathVariable("slug") String slug, + @AuthenticationPrincipal User user, + @Valid @RequestBody NewCommentParam newCommentParam) { + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + Comment comment = new Comment(newCommentParam.getBody(), user.getId(), item.getId()); + commentRepository.save(comment); + return ResponseEntity.status(201) + .body(commentResponse(commentQueryService.findById(comment.getId(), user).get())); + } + + @GetMapping + public ResponseEntity getComments( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + List comments = commentQueryService.findByItemId(item.getId(), user); + return ResponseEntity.ok( + new HashMap() { + { + put("comments", comments); + } + }); + } + + @RequestMapping(path = "{id}", method = RequestMethod.DELETE) + public ResponseEntity deleteComment( + @PathVariable("slug") String slug, + @PathVariable("id") String commentId, + @AuthenticationPrincipal User user) { + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + return commentRepository + .findById(item.getId(), commentId) + .map( + comment -> { + commentRepository.remove(comment); + return ResponseEntity.noContent().build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + private Map commentResponse(CommentData commentData) { + return new HashMap() { + { + put("comment", commentData); + } + }; + } +} + +@Getter +@NoArgsConstructor +@JsonRootName("comment") +class NewCommentParam { + @NotBlank(message = "can't be empty") + private String body; +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/CurrentUserApi.java b/.framework/java/backend/src/main/java/io/spring/api/CurrentUserApi.java new file mode 100644 index 00000000..e096aec0 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/CurrentUserApi.java @@ -0,0 +1,58 @@ +package io.spring.api; + +import io.spring.application.UserQueryService; +import io.spring.application.data.UserData; +import io.spring.application.data.UserWithToken; +import io.spring.application.user.UpdateUserCommand; +import io.spring.application.user.UpdateUserParam; +import io.spring.application.user.UserService; +import io.spring.core.user.User; +import java.util.HashMap; +import java.util.Map; +import javax.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/user") +@AllArgsConstructor +public class CurrentUserApi { + + private UserQueryService userQueryService; + private UserService userService; + + @GetMapping + public ResponseEntity currentUser( + @AuthenticationPrincipal User currentUser, + @RequestHeader(value = "Authorization") String authorization) { + UserData userData = userQueryService.findById(currentUser.getId()).get(); + return ResponseEntity.ok( + userResponse(new UserWithToken(userData, authorization.split(" ")[1]))); + } + + @PutMapping + public ResponseEntity updateProfile( + @AuthenticationPrincipal User currentUser, + @RequestHeader("Authorization") String token, + @Valid @RequestBody UpdateUserParam updateUserParam) { + + userService.updateUser(new UpdateUserCommand(currentUser, updateUserParam)); + UserData userData = userQueryService.findById(currentUser.getId()).get(); + return ResponseEntity.ok(userResponse(new UserWithToken(userData, token.split(" ")[1]))); + } + + private Map userResponse(UserWithToken userWithToken) { + return new HashMap() { + { + put("user", userWithToken); + } + }; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/ItemApi.java b/.framework/java/backend/src/main/java/io/spring/api/ItemApi.java new file mode 100644 index 00000000..69035e79 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ItemApi.java @@ -0,0 +1,88 @@ +package io.spring.api; + +import io.spring.api.exception.NoAuthorizationException; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ItemQueryService; +import io.spring.application.item.ItemCommandService; +import io.spring.application.item.UpdateItemParam; +import io.spring.application.data.ItemData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.service.AuthorizationService; +import io.spring.core.user.User; +import java.util.HashMap; +import java.util.Map; +import javax.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/items/{slug}") +@AllArgsConstructor +public class ItemApi { + private ItemQueryService itemQueryService; + private ItemRepository itemRepository; + private ItemCommandService itemCommandService; + + @GetMapping + public ResponseEntity item( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + return itemQueryService + .findBySlug(slug, user) + .map(itemData -> ResponseEntity.ok(itemResponse(itemData))) + .orElseThrow(ResourceNotFoundException::new); + } + + @PutMapping + public ResponseEntity updateItem( + @PathVariable("slug") String slug, + @AuthenticationPrincipal User user, + @Valid @RequestBody UpdateItemParam updateItemParam) { + return itemRepository + .findBySlug(slug) + .map( + item -> { + if (!AuthorizationService.canWriteItem(user, item)) { + throw new NoAuthorizationException(); + } + Item updatedItem = + itemCommandService.updateItem(item, updateItemParam); + return ResponseEntity.ok( + itemResponse( + itemQueryService.findBySlug(updatedItem.getSlug(), user).get())); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + @DeleteMapping + public ResponseEntity deleteItem( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + return itemRepository + .findBySlug(slug) + .map( + item -> { + if (!AuthorizationService.canWriteItem(user, item)) { + throw new NoAuthorizationException(); + } + itemRepository.remove(item); + return ResponseEntity.noContent().build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + private Map itemResponse(ItemData itemData) { + return new HashMap() { + { + put("item", itemData); + } + }; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/ItemFavoriteApi.java b/.framework/java/backend/src/main/java/io/spring/api/ItemFavoriteApi.java new file mode 100644 index 00000000..75eff6ad --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ItemFavoriteApi.java @@ -0,0 +1,62 @@ +package io.spring.api; + +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ItemQueryService; +import io.spring.application.data.ItemData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.core.user.User; +import java.util.HashMap; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "items/{slug}/favorite") +@AllArgsConstructor +public class ItemFavoriteApi { + private ItemFavoriteRepository itemFavoriteRepository; + private ItemRepository itemRepository; + private ItemQueryService itemQueryService; + + @PostMapping + public ResponseEntity favoriteItem( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ItemFavorite itemFavorite = new ItemFavorite(item.getId(), user.getId()); + itemFavoriteRepository.save(itemFavorite); + return responseItemData(itemQueryService.findBySlug(slug, user).get()); + } + + @DeleteMapping + public ResponseEntity unfavoriteItem( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + itemFavoriteRepository + .find(item.getId(), user.getId()) + .ifPresent( + favorite -> { + itemFavoriteRepository.remove(favorite); + }); + return responseItemData(itemQueryService.findBySlug(slug, user).get()); + } + + private ResponseEntity> responseItemData( + final ItemData itemData) { + return ResponseEntity.ok( + new HashMap() { + { + put("item", itemData); + } + }); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/ItemsApi.java b/.framework/java/backend/src/main/java/io/spring/api/ItemsApi.java new file mode 100644 index 00000000..210c2113 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ItemsApi.java @@ -0,0 +1,60 @@ +package io.spring.api; + +import io.spring.application.ItemQueryService; +import io.spring.application.Page; +import io.spring.application.item.ItemCommandService; +import io.spring.application.item.NewItemParam; +import io.spring.core.item.Item; +import io.spring.core.user.User; +import java.util.HashMap; +import javax.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/items") +@AllArgsConstructor +public class ItemsApi { + private ItemCommandService itemCommandService; + private ItemQueryService itemQueryService; + + @PostMapping + public ResponseEntity createItem( + @Valid @RequestBody NewItemParam newItemParam, @AuthenticationPrincipal User user) { + Item item = itemCommandService.createItem(newItemParam, user); + return ResponseEntity.ok( + new HashMap() { + { + put("item", itemQueryService.findById(item.getId(), user).get()); + } + }); + } + + @GetMapping(path = "feed") + public ResponseEntity getFeed( + @RequestParam(value = "offset", defaultValue = "0") int offset, + @RequestParam(value = "limit", defaultValue = "20") int limit, + @AuthenticationPrincipal User user) { + return ResponseEntity.ok(itemQueryService.findUserFeed(user, new Page(offset, limit))); + } + + @GetMapping + public ResponseEntity getItems( + @RequestParam(value = "offset", defaultValue = "0") int offset, + @RequestParam(value = "limit", defaultValue = "20") int limit, + @RequestParam(value = "tag", required = false) String tag, + @RequestParam(value = "favorited", required = false) String favoritedBy, + @RequestParam(value = "seller", required = false) String seller, + @AuthenticationPrincipal User user) { + return ResponseEntity.ok( + itemQueryService.findRecentItems( + tag, seller, favoritedBy, new Page(offset, limit), user)); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/ProfileApi.java b/.framework/java/backend/src/main/java/io/spring/api/ProfileApi.java new file mode 100644 index 00000000..1cf7bca3 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ProfileApi.java @@ -0,0 +1,78 @@ +package io.spring.api; + +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ProfileData; +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.util.HashMap; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "profiles/{username}") +@AllArgsConstructor +public class ProfileApi { + private ProfileQueryService profileQueryService; + private UserRepository userRepository; + + @GetMapping + public ResponseEntity getProfile( + @PathVariable("username") String username, @AuthenticationPrincipal User user) { + return profileQueryService + .findByUsername(username, user) + .map(this::profileResponse) + .orElseThrow(ResourceNotFoundException::new); + } + + @PostMapping(path = "follow") + public ResponseEntity follow( + @PathVariable("username") String username, @AuthenticationPrincipal User user) { + return userRepository + .findByUsername(username) + .map( + target -> { + FollowRelation followRelation = new FollowRelation(user.getId(), target.getId()); + userRepository.saveRelation(followRelation); + return profileResponse(profileQueryService.findByUsername(username, user).get()); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + @DeleteMapping(path = "follow") + public ResponseEntity unfollow( + @PathVariable("username") String username, @AuthenticationPrincipal User user) { + Optional userOptional = userRepository.findByUsername(username); + if (userOptional.isPresent()) { + User target = userOptional.get(); + return userRepository + .findRelation(user.getId(), target.getId()) + .map( + relation -> { + userRepository.removeRelation(relation); + return profileResponse(profileQueryService.findByUsername(username, user).get()); + }) + .orElseThrow(ResourceNotFoundException::new); + } else { + throw new ResourceNotFoundException(); + } + } + + private ResponseEntity profileResponse(ProfileData profile) { + return ResponseEntity.ok( + new HashMap() { + { + put("profile", profile); + } + }); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/TagsApi.java b/.framework/java/backend/src/main/java/io/spring/api/TagsApi.java new file mode 100644 index 00000000..e3991832 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/TagsApi.java @@ -0,0 +1,26 @@ +package io.spring.api; + +import io.spring.application.TagsQueryService; +import java.util.HashMap; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "tags") +@AllArgsConstructor +public class TagsApi { + private TagsQueryService tagsQueryService; + + @GetMapping + public ResponseEntity getTags() { + return ResponseEntity.ok( + new HashMap() { + { + put("tags", tagsQueryService.allTags()); + } + }); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/UsersApi.java b/.framework/java/backend/src/main/java/io/spring/api/UsersApi.java new file mode 100644 index 00000000..d91321e8 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/UsersApi.java @@ -0,0 +1,79 @@ +package io.spring.api; + +import static org.springframework.web.bind.annotation.RequestMethod.POST; + +import com.fasterxml.jackson.annotation.JsonRootName; +import io.spring.api.exception.InvalidAuthenticationException; +import io.spring.application.UserQueryService; +import io.spring.application.data.UserData; +import io.spring.application.data.UserWithToken; +import io.spring.application.user.RegisterParam; +import io.spring.application.user.UserService; +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import javax.validation.Valid; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +public class UsersApi { + private UserRepository userRepository; + private UserQueryService userQueryService; + private PasswordEncoder passwordEncoder; + private JwtService jwtService; + private UserService userService; + + @RequestMapping(path = "/users", method = POST) + public ResponseEntity createUser(@Valid @RequestBody RegisterParam registerParam) { + User user = userService.createUser(registerParam); + UserData userData = userQueryService.findById(user.getId()).get(); + return ResponseEntity.status(201) + .body(userResponse(new UserWithToken(userData, jwtService.toToken(user)))); + } + + @RequestMapping(path = "/users/login", method = POST) + public ResponseEntity userLogin(@Valid @RequestBody LoginParam loginParam) { + Optional optional = userRepository.findByEmail(loginParam.getEmail()); + if (optional.isPresent() + && passwordEncoder.matches(loginParam.getPassword(), optional.get().getPassword())) { + UserData userData = userQueryService.findById(optional.get().getId()).get(); + return ResponseEntity.ok( + userResponse(new UserWithToken(userData, jwtService.toToken(optional.get())))); + } else { + throw new InvalidAuthenticationException(); + } + } + + private Map userResponse(UserWithToken userWithToken) { + return new HashMap() { + { + put("user", userWithToken); + } + }; + } +} + +@Getter +@JsonRootName("user") +@NoArgsConstructor +class LoginParam { + @NotBlank(message = "can't be empty") + @Email(message = "should be an email") + private String email; + + @NotBlank(message = "can't be empty") + private String password; +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/CustomizeExceptionHandler.java b/.framework/java/backend/src/main/java/io/spring/api/exception/CustomizeExceptionHandler.java new file mode 100644 index 00000000..ade3ff4f --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/CustomizeExceptionHandler.java @@ -0,0 +1,109 @@ +package io.spring.api.exception; + +import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@RestControllerAdvice +public class CustomizeExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler({InvalidRequestException.class}) + public ResponseEntity handleInvalidRequest(RuntimeException e, WebRequest request) { + InvalidRequestException ire = (InvalidRequestException) e; + + List errorResources = + ire.getErrors().getFieldErrors().stream() + .map( + fieldError -> + new FieldErrorResource( + fieldError.getObjectName(), + fieldError.getField(), + fieldError.getCode(), + fieldError.getDefaultMessage())) + .collect(Collectors.toList()); + + ErrorResource error = new ErrorResource(errorResources); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + return handleExceptionInternal(e, error, headers, UNPROCESSABLE_ENTITY, request); + } + + @ExceptionHandler(InvalidAuthenticationException.class) + public ResponseEntity handleInvalidAuthentication( + InvalidAuthenticationException e, WebRequest request) { + return ResponseEntity.status(UNPROCESSABLE_ENTITY) + .body( + new HashMap() { + { + put("message", e.getMessage()); + } + }); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatus status, + WebRequest request) { + List errorResources = + e.getBindingResult().getFieldErrors().stream() + .map( + fieldError -> + new FieldErrorResource( + fieldError.getObjectName(), + fieldError.getField(), + fieldError.getCode(), + fieldError.getDefaultMessage())) + .collect(Collectors.toList()); + + return ResponseEntity.status(UNPROCESSABLE_ENTITY).body(new ErrorResource(errorResources)); + } + + @ExceptionHandler({ConstraintViolationException.class}) + @ResponseStatus(UNPROCESSABLE_ENTITY) + @ResponseBody + public ErrorResource handleConstraintViolation( + ConstraintViolationException ex, WebRequest request) { + List errors = new ArrayList<>(); + for (ConstraintViolation violation : ex.getConstraintViolations()) { + FieldErrorResource fieldErrorResource = + new FieldErrorResource( + violation.getRootBeanClass().getName(), + getParam(violation.getPropertyPath().toString()), + violation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(), + violation.getMessage()); + errors.add(fieldErrorResource); + } + + return new ErrorResource(errors); + } + + private String getParam(String s) { + String[] splits = s.split("\\."); + if (splits.length == 1) { + return s; + } else { + return String.join(".", Arrays.copyOfRange(splits, 2, splits.length)); + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/ErrorResource.java b/.framework/java/backend/src/main/java/io/spring/api/exception/ErrorResource.java new file mode 100644 index 00000000..1c278058 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/ErrorResource.java @@ -0,0 +1,18 @@ +package io.spring.api.exception; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonRootName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.util.List; + +@JsonSerialize(using = ErrorResourceSerializer.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@lombok.Getter +@JsonRootName("errors") +public class ErrorResource { + private List fieldErrors; + + public ErrorResource(List fieldErrorResources) { + this.fieldErrors = fieldErrorResources; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/ErrorResourceSerializer.java b/.framework/java/backend/src/main/java/io/spring/api/exception/ErrorResourceSerializer.java new file mode 100644 index 00000000..2ce3816a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/ErrorResourceSerializer.java @@ -0,0 +1,42 @@ +package io.spring.api.exception; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ErrorResourceSerializer extends JsonSerializer { + @Override + public void serialize(ErrorResource value, JsonGenerator gen, SerializerProvider serializers) + throws IOException, JsonProcessingException { + Map> json = new HashMap<>(); + gen.writeStartObject(); + gen.writeObjectFieldStart("errors"); + for (FieldErrorResource fieldErrorResource : value.getFieldErrors()) { + if (!json.containsKey(fieldErrorResource.getField())) { + json.put(fieldErrorResource.getField(), new ArrayList()); + } + json.get(fieldErrorResource.getField()).add(fieldErrorResource.getMessage()); + } + for (Map.Entry> pair : json.entrySet()) { + gen.writeArrayFieldStart(pair.getKey()); + pair.getValue() + .forEach( + content -> { + try { + gen.writeString(content); + } catch (IOException e) { + e.printStackTrace(); + } + }); + gen.writeEndArray(); + } + gen.writeEndObject(); + gen.writeEndObject(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/FieldErrorResource.java b/.framework/java/backend/src/main/java/io/spring/api/exception/FieldErrorResource.java new file mode 100644 index 00000000..13d57314 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/FieldErrorResource.java @@ -0,0 +1,15 @@ +package io.spring.api.exception; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@AllArgsConstructor +public class FieldErrorResource { + private String resource; + private String field; + private String code; + private String message; +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/InvalidAuthenticationException.java b/.framework/java/backend/src/main/java/io/spring/api/exception/InvalidAuthenticationException.java new file mode 100644 index 00000000..96af7a83 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/InvalidAuthenticationException.java @@ -0,0 +1,8 @@ +package io.spring.api.exception; + +public class InvalidAuthenticationException extends RuntimeException { + + public InvalidAuthenticationException() { + super("invalid email or password"); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/InvalidRequestException.java b/.framework/java/backend/src/main/java/io/spring/api/exception/InvalidRequestException.java new file mode 100644 index 00000000..68b6c868 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/InvalidRequestException.java @@ -0,0 +1,17 @@ +package io.spring.api.exception; + +import org.springframework.validation.Errors; + +@SuppressWarnings("serial") +public class InvalidRequestException extends RuntimeException { + private final Errors errors; + + public InvalidRequestException(Errors errors) { + super(""); + this.errors = errors; + } + + public Errors getErrors() { + return errors; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/NoAuthorizationException.java b/.framework/java/backend/src/main/java/io/spring/api/exception/NoAuthorizationException.java new file mode 100644 index 00000000..67414233 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/NoAuthorizationException.java @@ -0,0 +1,7 @@ +package io.spring.api.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class NoAuthorizationException extends RuntimeException {} diff --git a/.framework/java/backend/src/main/java/io/spring/api/exception/ResourceNotFoundException.java b/.framework/java/backend/src/main/java/io/spring/api/exception/ResourceNotFoundException.java new file mode 100644 index 00000000..8401e526 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package io.spring.api.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException {} diff --git a/.framework/java/backend/src/main/java/io/spring/api/security/JwtTokenFilter.java b/.framework/java/backend/src/main/java/io/spring/api/security/JwtTokenFilter.java new file mode 100644 index 00000000..1b5c5014 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/security/JwtTokenFilter.java @@ -0,0 +1,62 @@ +package io.spring.api.security; + +import io.spring.core.service.JwtService; +import io.spring.core.user.UserRepository; +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +@SuppressWarnings("SpringJavaAutowiringInspection") +public class JwtTokenFilter extends OncePerRequestFilter { + @Autowired private UserRepository userRepository; + @Autowired private JwtService jwtService; + private final String header = "Authorization"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + getTokenString(request.getHeader(header)) + .flatMap(token -> jwtService.getSubFromToken(token)) + .ifPresent( + id -> { + if (SecurityContextHolder.getContext().getAuthentication() == null) { + userRepository + .findById(id) + .ifPresent( + user -> { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken( + user, null, Collections.emptyList()); + authenticationToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + }); + } + }); + + filterChain.doFilter(request, response); + } + + private Optional getTokenString(String header) { + if (header == null) { + return Optional.empty(); + } else { + String[] split = header.split(" "); + if (split.length < 2) { + return Optional.empty(); + } else { + return Optional.ofNullable(split[1]); + } + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/security/WebSecurityConfig.java b/.framework/java/backend/src/main/java/io/spring/api/security/WebSecurityConfig.java new file mode 100644 index 00000000..bdffa941 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/security/WebSecurityConfig.java @@ -0,0 +1,83 @@ +package io.spring.api.security; + +import static java.util.Arrays.asList; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Bean + public JwtTokenFilter jwtTokenFilter() { + return new JwtTokenFilter(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + + http.csrf() + .disable() + .cors() + .and() + .exceptionHandling() + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers(HttpMethod.OPTIONS) + .permitAll() + .antMatchers("/graphiql") + .permitAll() + .antMatchers("/graphql") + .permitAll() + .antMatchers(HttpMethod.GET, "/items/feed") + .authenticated() + .antMatchers(HttpMethod.POST, "/users", "/users/login") + .permitAll() + .antMatchers(HttpMethod.GET, "/items/**", "/profiles/**", "/tags") + .permitAll() + .anyRequest() + .authenticated(); + + http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + final CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(asList("*")); + configuration.setAllowedMethods(asList("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH")); + // setAllowCredentials(true) is important, otherwise: + // The value of the 'Access-Control-Allow-Origin' header in the response must not be the + // wildcard '*' when the request's credentials mode is 'include'. + configuration.setAllowCredentials(false); + // setAllowedHeaders is important! Without it, OPTIONS preflight request + // will fail with 403 Invalid CORS request + configuration.setAllowedHeaders(asList("Authorization", "Cache-Control", "Content-Type")); + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/CommentQueryService.java b/.framework/java/backend/src/main/java/io/spring/application/CommentQueryService.java new file mode 100644 index 00000000..dfba6eff --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/CommentQueryService.java @@ -0,0 +1,85 @@ +package io.spring.application; + +import io.spring.application.data.CommentData; +import io.spring.core.user.User; +import io.spring.infrastructure.mybatis.readservice.CommentReadService; +import io.spring.infrastructure.mybatis.readservice.UserRelationshipQueryService; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import org.joda.time.DateTime; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class CommentQueryService { + private CommentReadService commentReadService; + private UserRelationshipQueryService userRelationshipQueryService; + + public Optional findById(String id, User user) { + CommentData commentData = commentReadService.findById(id); + if (commentData == null) { + return Optional.empty(); + } else { + commentData + .getProfileData() + .setFollowing( + userRelationshipQueryService.isUserFollowing( + user.getId(), commentData.getProfileData().getId())); + } + return Optional.ofNullable(commentData); + } + + public List findByItemId(String itemId, User user) { + List comments = commentReadService.findByItemId(itemId); + if (comments.size() > 0 && user != null) { + Set followingSellers = + userRelationshipQueryService.followingSellers( + user.getId(), + comments.stream() + .map(commentData -> commentData.getProfileData().getId()) + .collect(Collectors.toList())); + comments.forEach( + commentData -> { + if (followingSellers.contains(commentData.getProfileData().getId())) { + commentData.getProfileData().setFollowing(true); + } + }); + } + return comments; + } + + public CursorPager findByItemIdWithCursor( + String itemId, User user, CursorPageParameter page) { + List comments = commentReadService.findByItemIdWithCursor(itemId, page); + if (comments.isEmpty()) { + return new CursorPager<>(new ArrayList<>(), page.getDirection(), false); + } + if (user != null) { + Set followingSellers = + userRelationshipQueryService.followingSellers( + user.getId(), + comments.stream() + .map(commentData -> commentData.getProfileData().getId()) + .collect(Collectors.toList())); + comments.forEach( + commentData -> { + if (followingSellers.contains(commentData.getProfileData().getId())) { + commentData.getProfileData().setFollowing(true); + } + }); + } + boolean hasExtra = comments.size() > page.getLimit(); + if (hasExtra) { + comments.remove(page.getLimit()); + } + if (!page.isNext()) { + Collections.reverse(comments); + } + return new CursorPager<>(comments, page.getDirection(), hasExtra); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/CursorPageParameter.java b/.framework/java/backend/src/main/java/io/spring/application/CursorPageParameter.java new file mode 100644 index 00000000..19531373 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/CursorPageParameter.java @@ -0,0 +1,40 @@ +package io.spring.application; + +import io.spring.application.CursorPager.Direction; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class CursorPageParameter { + private static final int MAX_LIMIT = 1000; + private int limit = 20; + private T cursor; + private Direction direction; + + public CursorPageParameter(T cursor, int limit, Direction direction) { + setLimit(limit); + setCursor(cursor); + setDirection(direction); + } + + public boolean isNext() { + return direction == Direction.NEXT; + } + + public int getQueryLimit() { + return limit + 1; + } + + private void setCursor(T cursor) { + this.cursor = cursor; + } + + private void setLimit(int limit) { + if (limit > MAX_LIMIT) { + this.limit = MAX_LIMIT; + } else if (limit > 0) { + this.limit = limit; + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/CursorPager.java b/.framework/java/backend/src/main/java/io/spring/application/CursorPager.java new file mode 100644 index 00000000..13d55d4c --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/CursorPager.java @@ -0,0 +1,44 @@ +package io.spring.application; + +import java.util.List; +import lombok.Getter; + +@Getter +public class CursorPager { + private List data; + private boolean next; + private boolean previous; + + public CursorPager(List data, Direction direction, boolean hasExtra) { + this.data = data; + + if (direction == Direction.NEXT) { + this.previous = false; + this.next = hasExtra; + } else { + this.next = false; + this.previous = hasExtra; + } + } + + public boolean hasNext() { + return next; + } + + public boolean hasPrevious() { + return previous; + } + + public PageCursor getStartCursor() { + return data.isEmpty() ? null : data.get(0).getCursor(); + } + + public PageCursor getEndCursor() { + return data.isEmpty() ? null : data.get(data.size() - 1).getCursor(); + } + + public enum Direction { + PREV, + NEXT + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/DateTimeCursor.java b/.framework/java/backend/src/main/java/io/spring/application/DateTimeCursor.java new file mode 100644 index 00000000..cfcc86bc --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/DateTimeCursor.java @@ -0,0 +1,23 @@ +package io.spring.application; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; + +public class DateTimeCursor extends PageCursor { + + public DateTimeCursor(DateTime data) { + super(data); + } + + @Override + public String toString() { + return String.valueOf(getData().getMillis()); + } + + public static DateTime parse(String cursor) { + if (cursor == null) { + return null; + } + return new DateTime().withMillis(Long.parseLong(cursor)).withZone(DateTimeZone.UTC); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/ItemQueryService.java b/.framework/java/backend/src/main/java/io/spring/application/ItemQueryService.java new file mode 100644 index 00000000..3ac17f70 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/ItemQueryService.java @@ -0,0 +1,184 @@ +package io.spring.application; + +import static java.util.stream.Collectors.toList; + +import io.spring.application.data.ItemData; +import io.spring.application.data.ItemDataList; +import io.spring.application.data.ItemFavoriteCount; +import io.spring.core.user.User; +import io.spring.infrastructure.mybatis.readservice.ItemFavoritesReadService; +import io.spring.infrastructure.mybatis.readservice.ItemReadService; +import io.spring.infrastructure.mybatis.readservice.UserRelationshipQueryService; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import lombok.AllArgsConstructor; +import org.joda.time.DateTime; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class ItemQueryService { + private ItemReadService itemReadService; + private UserRelationshipQueryService userRelationshipQueryService; + private ItemFavoritesReadService itemFavoritesReadService; + + public Optional findById(String id, User user) { + ItemData itemData = itemReadService.findById(id); + if (itemData == null) { + return Optional.empty(); + } else { + if (user != null) { + fillExtraInfo(id, user, itemData); + } + return Optional.of(itemData); + } + } + + public Optional findBySlug(String slug, User user) { + ItemData itemData = itemReadService.findBySlug(slug); + if (itemData == null) { + return Optional.empty(); + } else { + if (user != null) { + fillExtraInfo(itemData.getId(), user, itemData); + } + return Optional.of(itemData); + } + } + + public CursorPager findRecentItemsWithCursor( + String tag, + String seller, + String favoritedBy, + CursorPageParameter page, + User currentUser) { + List itemIds = + itemReadService.findItemsWithCursor(tag, seller, favoritedBy, page); + if (itemIds.size() == 0) { + return new CursorPager<>(new ArrayList<>(), page.getDirection(), false); + } else { + boolean hasExtra = itemIds.size() > page.getLimit(); + if (hasExtra) { + itemIds.remove(page.getLimit()); + } + if (!page.isNext()) { + Collections.reverse(itemIds); + } + + List items = itemReadService.findItems(itemIds); + fillExtraInfo(items, currentUser); + + return new CursorPager<>(items, page.getDirection(), hasExtra); + } + } + + public CursorPager findUserFeedWithCursor( + User user, CursorPageParameter page) { + List followedUsers = userRelationshipQueryService.followedUsers(user.getId()); + if (followedUsers.size() == 0) { + return new CursorPager<>(new ArrayList<>(), page.getDirection(), false); + } else { + List items = + itemReadService.findItemsOfSellersWithCursor(followedUsers, page); + boolean hasExtra = items.size() > page.getLimit(); + if (hasExtra) { + items.remove(page.getLimit()); + } + if (!page.isNext()) { + Collections.reverse(items); + } + fillExtraInfo(items, user); + return new CursorPager<>(items, page.getDirection(), hasExtra); + } + } + + public ItemDataList findRecentItems( + String tag, String seller, String favoritedBy, Page page, User currentUser) { + List itemIds = itemReadService.queryItems(tag, seller, favoritedBy, page); + int itemCount = itemReadService.countItem(tag, seller, favoritedBy); + if (itemIds.size() == 0) { + return new ItemDataList(new ArrayList<>(), itemCount); + } else { + List items = itemReadService.findItems(itemIds); + fillExtraInfo(items, currentUser); + return new ItemDataList(items, itemCount); + } + } + + public ItemDataList findUserFeed(User user, Page page) { + List followedUsers = userRelationshipQueryService.followedUsers(user.getId()); + if (followedUsers.size() == 0) { + return new ItemDataList(new ArrayList<>(), 0); + } else { + List items = itemReadService.findItemsOfSellers(followedUsers, page); + fillExtraInfo(items, user); + int count = itemReadService.countFeedSize(followedUsers); + return new ItemDataList(items, count); + } + } + + private void fillExtraInfo(List items, User currentUser) { + setFavoriteCount(items); + if (currentUser != null) { + setIsFavorite(items, currentUser); + setIsFollowingSeller(items, currentUser); + } + } + + private void setIsFollowingSeller(List items, User currentUser) { + Set followingSellers = + userRelationshipQueryService.followingSellers( + currentUser.getId(), + items.stream() + .map(itemData1 -> itemData1.getProfileData().getId()) + .collect(toList())); + items.forEach( + itemData -> { + if (followingSellers.contains(itemData.getProfileData().getId())) { + itemData.getProfileData().setFollowing(true); + } + }); + } + + private void setFavoriteCount(List items) { + List favoritesCounts = + itemFavoritesReadService.itemsFavoriteCount( + items.stream().map(ItemData::getId).collect(toList())); + Map countMap = new HashMap<>(); + favoritesCounts.forEach( + item -> { + countMap.put(item.getId(), item.getCount()); + }); + items.forEach( + itemData -> itemData.setFavoritesCount(countMap.get(itemData.getId()))); + } + + private void setIsFavorite(List items, User currentUser) { + Set favoritedItems = + itemFavoritesReadService.userFavorites( + items.stream().map(itemData -> itemData.getId()).collect(toList()), + currentUser); + + items.forEach( + itemData -> { + if (favoritedItems.contains(itemData.getId())) { + itemData.setFavorited(true); + } + }); + } + + private void fillExtraInfo(String id, User user, ItemData itemData) { + itemData.setFavorited(itemFavoritesReadService.isUserFavorite(user.getId(), id)); + itemData.setFavoritesCount(itemFavoritesReadService.itemFavoriteCount(id)); + itemData + .getProfileData() + .setFollowing( + userRelationshipQueryService.isUserFollowing( + user.getId(), itemData.getProfileData().getId())); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/Node.java b/.framework/java/backend/src/main/java/io/spring/application/Node.java new file mode 100644 index 00000000..e4ccac8a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/Node.java @@ -0,0 +1,5 @@ +package io.spring.application; + +public interface Node { + PageCursor getCursor(); +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/Page.java b/.framework/java/backend/src/main/java/io/spring/application/Page.java new file mode 100644 index 00000000..d273e994 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/Page.java @@ -0,0 +1,31 @@ +package io.spring.application; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +public class Page { + private static final int MAX_LIMIT = 100; + private int offset = 0; + private int limit = 20; + + public Page(int offset, int limit) { + setOffset(offset); + setLimit(limit); + } + + private void setOffset(int offset) { + if (offset > 0) { + this.offset = offset; + } + } + + private void setLimit(int limit) { + if (limit > MAX_LIMIT) { + this.limit = MAX_LIMIT; + } else if (limit > 0) { + this.limit = limit; + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/PageCursor.java b/.framework/java/backend/src/main/java/io/spring/application/PageCursor.java new file mode 100644 index 00000000..0279f3b2 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/PageCursor.java @@ -0,0 +1,18 @@ +package io.spring.application; + +public abstract class PageCursor { + private T data; + + public PageCursor(T data) { + this.data = data; + } + + public T getData() { + return data; + } + + @Override + public String toString() { + return data.toString(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/ProfileQueryService.java b/.framework/java/backend/src/main/java/io/spring/application/ProfileQueryService.java new file mode 100644 index 00000000..d92542d5 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/ProfileQueryService.java @@ -0,0 +1,35 @@ +package io.spring.application; + +import io.spring.application.data.ProfileData; +import io.spring.application.data.UserData; +import io.spring.core.user.User; +import io.spring.infrastructure.mybatis.readservice.UserReadService; +import io.spring.infrastructure.mybatis.readservice.UserRelationshipQueryService; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +public class ProfileQueryService { + private UserReadService userReadService; + private UserRelationshipQueryService userRelationshipQueryService; + + public Optional findByUsername(String username, User currentUser) { + UserData userData = userReadService.findByUsername(username); + if (userData == null) { + return Optional.empty(); + } else { + ProfileData profileData = + new ProfileData( + userData.getId(), + userData.getUsername(), + userData.getBio(), + userData.getImage(), + currentUser != null + && userRelationshipQueryService.isUserFollowing( + currentUser.getId(), userData.getId())); + return Optional.of(profileData); + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/TagsQueryService.java b/.framework/java/backend/src/main/java/io/spring/application/TagsQueryService.java new file mode 100644 index 00000000..12e0790c --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/TagsQueryService.java @@ -0,0 +1,16 @@ +package io.spring.application; + +import io.spring.infrastructure.mybatis.readservice.TagReadService; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class TagsQueryService { + private TagReadService tagReadService; + + public List allTags() { + return tagReadService.all(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/UserQueryService.java b/.framework/java/backend/src/main/java/io/spring/application/UserQueryService.java new file mode 100644 index 00000000..f0f901ae --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/UserQueryService.java @@ -0,0 +1,17 @@ +package io.spring.application; + +import io.spring.application.data.UserData; +import io.spring.infrastructure.mybatis.readservice.UserReadService; +import java.util.Optional; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class UserQueryService { + private UserReadService userReadService; + + public Optional findById(String id) { + return Optional.ofNullable(userReadService.findById(id)); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/CommentData.java b/.framework/java/backend/src/main/java/io/spring/application/data/CommentData.java new file mode 100644 index 00000000..a31dbf43 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/CommentData.java @@ -0,0 +1,29 @@ +package io.spring.application.data; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.spring.application.DateTimeCursor; +import io.spring.application.Node; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.joda.time.DateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentData implements Node { + private String id; + private String body; + @JsonIgnore private String itemId; + private DateTime createdAt; + private DateTime updatedAt; + + @JsonProperty("seller") + private ProfileData profileData; + + @Override + public DateTimeCursor getCursor() { + return new DateTimeCursor(createdAt); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/ItemData.java b/.framework/java/backend/src/main/java/io/spring/application/data/ItemData.java new file mode 100644 index 00000000..e65f04cb --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/ItemData.java @@ -0,0 +1,33 @@ +package io.spring.application.data; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.spring.application.DateTimeCursor; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.joda.time.DateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ItemData implements io.spring.application.Node { + private String id; + private String slug; + private String title; + private String description; + private String image; + private boolean favorited; + private int favoritesCount; + private DateTime createdAt; + private DateTime updatedAt; + private List tagList; + + @JsonProperty("seller") + private ProfileData profileData; + + @Override + public DateTimeCursor getCursor() { + return new DateTimeCursor(updatedAt); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/ItemDataList.java b/.framework/java/backend/src/main/java/io/spring/application/data/ItemDataList.java new file mode 100644 index 00000000..983d17c9 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/ItemDataList.java @@ -0,0 +1,20 @@ +package io.spring.application.data; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Getter; + +@Getter +public class ItemDataList { + @JsonProperty("items") + private final List itemDatas; + + @JsonProperty("itemsCount") + private final int count; + + public ItemDataList(List itemDatas, int count) { + + this.itemDatas = itemDatas; + this.count = count; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/ItemFavoriteCount.java b/.framework/java/backend/src/main/java/io/spring/application/data/ItemFavoriteCount.java new file mode 100644 index 00000000..6d875df0 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/ItemFavoriteCount.java @@ -0,0 +1,9 @@ +package io.spring.application.data; + +import lombok.Value; + +@Value +public class ItemFavoriteCount { + private String id; + private Integer count; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/ProfileData.java b/.framework/java/backend/src/main/java/io/spring/application/data/ProfileData.java new file mode 100644 index 00000000..82ef5f95 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/ProfileData.java @@ -0,0 +1,17 @@ +package io.spring.application.data; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProfileData { + @JsonIgnore private String id; + private String username; + private String bio; + private String image; + private boolean following; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/UserData.java b/.framework/java/backend/src/main/java/io/spring/application/data/UserData.java new file mode 100644 index 00000000..c50cc190 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/UserData.java @@ -0,0 +1,16 @@ +package io.spring.application.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserData { + private String id; + private String email; + private String username; + private String bio; + private String image; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/data/UserWithToken.java b/.framework/java/backend/src/main/java/io/spring/application/data/UserWithToken.java new file mode 100644 index 00000000..eac7f1b6 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/data/UserWithToken.java @@ -0,0 +1,20 @@ +package io.spring.application.data; + +import lombok.Getter; + +@Getter +public class UserWithToken { + private String email; + private String username; + private String bio; + private String image; + private String token; + + public UserWithToken(UserData userData, String token) { + this.email = userData.getEmail(); + this.username = userData.getUsername(); + this.bio = userData.getBio(); + this.image = userData.getImage(); + this.token = token; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemConstraint.java b/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemConstraint.java new file mode 100644 index 00000000..d0d0aaa5 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemConstraint.java @@ -0,0 +1,21 @@ +package io.spring.application.item; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.validation.Constraint; +import javax.validation.Payload; + +@Documented +@Constraint(validatedBy = DuplicatedItemValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface DuplicatedItemConstraint { + String message() default "item name exists"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemValidator.java b/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemValidator.java new file mode 100644 index 00000000..658acf06 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemValidator.java @@ -0,0 +1,18 @@ +package io.spring.application.item; + +import io.spring.application.ItemQueryService; +import io.spring.core.item.Item; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import org.springframework.beans.factory.annotation.Autowired; + +class DuplicatedItemValidator + implements ConstraintValidator { + + @Autowired private ItemQueryService itemQueryService; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return !itemQueryService.findBySlug(Item.toSlug(value), null).isPresent(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/item/ItemCommandService.java b/.framework/java/backend/src/main/java/io/spring/application/item/ItemCommandService.java new file mode 100644 index 00000000..528d6d6d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/ItemCommandService.java @@ -0,0 +1,38 @@ +package io.spring.application.item; + +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.user.User; +import javax.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +@Service +@Validated +@AllArgsConstructor +public class ItemCommandService { + + private ItemRepository itemRepository; + + public Item createItem(@Valid NewItemParam newItemParam, User creator) { + Item item = + new Item( + newItemParam.getTitle(), + newItemParam.getDescription(), + newItemParam.getImage(), + newItemParam.getTagList(), + creator.getId()); + itemRepository.save(item); + return item; + } + + public Item updateItem(Item item, @Valid UpdateItemParam updateItemParam) { + item.update( + updateItemParam.getTitle(), + updateItemParam.getDescription(), + updateItemParam.getImage()); + itemRepository.save(item); + return item; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/item/NewItemParam.java b/.framework/java/backend/src/main/java/io/spring/application/item/NewItemParam.java new file mode 100644 index 00000000..89a4d84a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/NewItemParam.java @@ -0,0 +1,27 @@ +package io.spring.application.item; + +import com.fasterxml.jackson.annotation.JsonRootName; +import java.util.List; +import javax.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@JsonRootName("item") +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NewItemParam { + @NotBlank(message = "can't be empty") + @DuplicatedItemConstraint + private String title; + + @NotBlank(message = "can't be empty") + private String description; + + private String image; + + private List tagList; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/item/UpdateItemParam.java b/.framework/java/backend/src/main/java/io/spring/application/item/UpdateItemParam.java new file mode 100644 index 00000000..d2f214ef --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/UpdateItemParam.java @@ -0,0 +1,16 @@ +package io.spring.application.item; + +import com.fasterxml.jackson.annotation.JsonRootName; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonRootName("item") +public class UpdateItemParam { + private String title = ""; + private String image = ""; + private String description = ""; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailConstraint.java b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailConstraint.java new file mode 100644 index 00000000..e41eb009 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailConstraint.java @@ -0,0 +1,16 @@ +package io.spring.application.user; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.validation.Constraint; +import javax.validation.Payload; + +@Constraint(validatedBy = DuplicatedEmailValidator.class) +@Retention(RetentionPolicy.RUNTIME) +public @interface DuplicatedEmailConstraint { + String message() default "duplicated email"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailValidator.java b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailValidator.java new file mode 100644 index 00000000..e3071146 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailValidator.java @@ -0,0 +1,17 @@ +package io.spring.application.user; + +import io.spring.core.user.UserRepository; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import org.springframework.beans.factory.annotation.Autowired; + +public class DuplicatedEmailValidator + implements ConstraintValidator { + + @Autowired private UserRepository userRepository; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return (value == null || value.isEmpty()) || !userRepository.findByEmail(value).isPresent(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameConstraint.java b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameConstraint.java new file mode 100644 index 00000000..4f365b78 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameConstraint.java @@ -0,0 +1,16 @@ +package io.spring.application.user; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.validation.Constraint; +import javax.validation.Payload; + +@Constraint(validatedBy = DuplicatedUsernameValidator.class) +@Retention(RetentionPolicy.RUNTIME) +@interface DuplicatedUsernameConstraint { + String message() default "duplicated username"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameValidator.java b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameValidator.java new file mode 100644 index 00000000..ae1fd21a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameValidator.java @@ -0,0 +1,17 @@ +package io.spring.application.user; + +import io.spring.core.user.UserRepository; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import org.springframework.beans.factory.annotation.Autowired; + +class DuplicatedUsernameValidator + implements ConstraintValidator { + + @Autowired private UserRepository userRepository; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return (value == null || value.isEmpty()) || !userRepository.findByUsername(value).isPresent(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/RegisterParam.java b/.framework/java/backend/src/main/java/io/spring/application/user/RegisterParam.java new file mode 100644 index 00000000..3ba1234d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/RegisterParam.java @@ -0,0 +1,26 @@ +package io.spring.application.user; + +import com.fasterxml.jackson.annotation.JsonRootName; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@JsonRootName("user") +@AllArgsConstructor +@NoArgsConstructor +public class RegisterParam { + @NotBlank(message = "can't be empty") + @Email(message = "should be an email") + @DuplicatedEmailConstraint + private String email; + + @NotBlank(message = "can't be empty") + @DuplicatedUsernameConstraint + private String username; + + @NotBlank(message = "can't be empty") + private String password; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/UpdateUserCommand.java b/.framework/java/backend/src/main/java/io/spring/application/user/UpdateUserCommand.java new file mode 100644 index 00000000..9df52301 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/UpdateUserCommand.java @@ -0,0 +1,14 @@ +package io.spring.application.user; + +import io.spring.core.user.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@UpdateUserConstraint +public class UpdateUserCommand { + + private User targetUser; + private UpdateUserParam param; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/UpdateUserParam.java b/.framework/java/backend/src/main/java/io/spring/application/user/UpdateUserParam.java new file mode 100644 index 00000000..54cd7747 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/UpdateUserParam.java @@ -0,0 +1,25 @@ +package io.spring.application.user; + +import com.fasterxml.jackson.annotation.JsonRootName; +import javax.validation.constraints.Email; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@JsonRootName("user") +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UpdateUserParam { + + @Builder.Default + @Email(message = "should be an email") + private String email = ""; + + @Builder.Default private String password = ""; + @Builder.Default private String username = ""; + @Builder.Default private String bio = ""; + @Builder.Default private String image = ""; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/user/UserService.java b/.framework/java/backend/src/main/java/io/spring/application/user/UserService.java new file mode 100644 index 00000000..48c6735b --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/user/UserService.java @@ -0,0 +1,106 @@ +package io.spring.application.user; + +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.validation.Constraint; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import javax.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +@Service +@Validated +public class UserService { + private UserRepository userRepository; + private String defaultImage; + private PasswordEncoder passwordEncoder; + + @Autowired + public UserService( + UserRepository userRepository, + @Value("${image.default}") String defaultImage, + PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.defaultImage = defaultImage; + this.passwordEncoder = passwordEncoder; + } + + public User createUser(@Valid RegisterParam registerParam) { + User user = + new User( + registerParam.getEmail(), + registerParam.getUsername(), + passwordEncoder.encode(registerParam.getPassword()), + "", + defaultImage); + userRepository.save(user); + return user; + } + + public void updateUser(@Valid UpdateUserCommand command) { + User user = command.getTargetUser(); + UpdateUserParam updateUserParam = command.getParam(); + user.update( + updateUserParam.getEmail(), + updateUserParam.getUsername(), + updateUserParam.getPassword(), + updateUserParam.getBio(), + updateUserParam.getImage()); + userRepository.save(user); + } +} + +@Constraint(validatedBy = UpdateUserValidator.class) +@Retention(RetentionPolicy.RUNTIME) +@interface UpdateUserConstraint { + + String message() default "invalid update param"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} + +class UpdateUserValidator implements ConstraintValidator { + + @Autowired private UserRepository userRepository; + + @Override + public boolean isValid(UpdateUserCommand value, ConstraintValidatorContext context) { + String inputEmail = value.getParam().getEmail(); + String inputUsername = value.getParam().getUsername(); + final User targetUser = value.getTargetUser(); + + boolean isEmailValid = + userRepository.findByEmail(inputEmail).map(user -> user.equals(targetUser)).orElse(true); + boolean isUsernameValid = + userRepository + .findByUsername(inputUsername) + .map(user -> user.equals(targetUser)) + .orElse(true); + if (isEmailValid && isUsernameValid) { + return true; + } else { + context.disableDefaultConstraintViolation(); + if (!isEmailValid) { + context + .buildConstraintViolationWithTemplate("email already exist") + .addPropertyNode("email") + .addConstraintViolation(); + } + if (!isUsernameValid) { + context + .buildConstraintViolationWithTemplate("username already exist") + .addPropertyNode("username") + .addConstraintViolation(); + } + return false; + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/comment/Comment.java b/.framework/java/backend/src/main/java/io/spring/core/comment/Comment.java new file mode 100644 index 00000000..a7a6bc4b --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/comment/Comment.java @@ -0,0 +1,26 @@ +package io.spring.core.comment; + +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.joda.time.DateTime; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = "id") +public class Comment { + private String id; + private String body; + private String userId; + private String itemId; + private DateTime createdAt; + + public Comment(String body, String userId, String itemId) { + this.id = UUID.randomUUID().toString(); + this.body = body; + this.userId = userId; + this.itemId = itemId; + this.createdAt = new DateTime(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/comment/CommentRepository.java b/.framework/java/backend/src/main/java/io/spring/core/comment/CommentRepository.java new file mode 100644 index 00000000..ad3ab070 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/comment/CommentRepository.java @@ -0,0 +1,11 @@ +package io.spring.core.comment; + +import java.util.Optional; + +public interface CommentRepository { + void save(Comment comment); + + Optional findById(String itemId, String id); + + void remove(Comment comment); +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavorite.java b/.framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavorite.java new file mode 100644 index 00000000..39ce24d6 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavorite.java @@ -0,0 +1,18 @@ +package io.spring.core.favorite; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +@EqualsAndHashCode +public class ItemFavorite { + private String itemId; + private String userId; + + public ItemFavorite(String itemId, String userId) { + this.itemId = itemId; + this.userId = userId; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavoriteRepository.java b/.framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavoriteRepository.java new file mode 100644 index 00000000..61db705a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavoriteRepository.java @@ -0,0 +1,11 @@ +package io.spring.core.favorite; + +import java.util.Optional; + +public interface ItemFavoriteRepository { + void save(ItemFavorite itemFavorite); + + Optional find(String itemId, String userId); + + void remove(ItemFavorite favorite); +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/item/Item.java b/.framework/java/backend/src/main/java/io/spring/core/item/Item.java new file mode 100644 index 00000000..c13a724d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/item/Item.java @@ -0,0 +1,70 @@ +package io.spring.core.item; + +import static java.util.stream.Collectors.toList; + +import io.spring.Util; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.joda.time.DateTime; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = {"id"}) +public class Item { + private String sellerId; + private String id; + private String slug; + private String title; + private String description; + private String image; + private List tags; + private DateTime createdAt; + private DateTime updatedAt; + + public Item( + String title, String description, String image, List tagList, String sellerId) { + this(title, description, image, tagList, sellerId, new DateTime()); + } + + public Item( + String title, + String description, + String image, + List tagList, + String sellerId, + DateTime createdAt) { + this.id = UUID.randomUUID().toString(); + this.slug = toSlug(title); + this.title = title; + this.description = description; + this.image = image; + this.tags = new HashSet<>(tagList).stream().map(Tag::new).collect(toList()); + this.sellerId = sellerId; + this.createdAt = createdAt; + this.updatedAt = createdAt; + } + + public void update(String title, String description, String image) { + if (!Util.isEmpty(title)) { + this.title = title; + this.slug = toSlug(title); + this.updatedAt = new DateTime(); + } + if (!Util.isEmpty(description)) { + this.description = description; + this.updatedAt = new DateTime(); + } + if (!Util.isEmpty(image)) { + this.image = image; + this.updatedAt = new DateTime(); + } + } + + public static String toSlug(String title) { + return title.toLowerCase().replaceAll("[\\&|[\\uFE30-\\uFFA0]|\\’|\\”|\\s\\?\\,\\.]+", "-"); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/item/ItemRepository.java b/.framework/java/backend/src/main/java/io/spring/core/item/ItemRepository.java new file mode 100644 index 00000000..6dea2ea9 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/item/ItemRepository.java @@ -0,0 +1,14 @@ +package io.spring.core.item; + +import java.util.Optional; + +public interface ItemRepository { + + void save(Item item); + + Optional findById(String id); + + Optional findBySlug(String slug); + + void remove(Item item); +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/item/Tag.java b/.framework/java/backend/src/main/java/io/spring/core/item/Tag.java new file mode 100644 index 00000000..1433450d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/item/Tag.java @@ -0,0 +1,19 @@ +package io.spring.core.item; + +import java.util.UUID; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +@EqualsAndHashCode(of = "name") +public class Tag { + private String id; + private String name; + + public Tag(String name) { + this.id = UUID.randomUUID().toString(); + this.name = name; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/service/AuthorizationService.java b/.framework/java/backend/src/main/java/io/spring/core/service/AuthorizationService.java new file mode 100644 index 00000000..94b949ba --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/service/AuthorizationService.java @@ -0,0 +1,15 @@ +package io.spring.core.service; + +import io.spring.core.item.Item; +import io.spring.core.comment.Comment; +import io.spring.core.user.User; + +public class AuthorizationService { + public static boolean canWriteItem(User user, Item item) { + return user.getId().equals(item.getSellerId()); + } + + public static boolean canWriteComment(User user, Item item, Comment comment) { + return user.getId().equals(item.getSellerId()) || user.getId().equals(comment.getUserId()); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/service/JwtService.java b/.framework/java/backend/src/main/java/io/spring/core/service/JwtService.java new file mode 100644 index 00000000..d1430768 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/service/JwtService.java @@ -0,0 +1,12 @@ +package io.spring.core.service; + +import io.spring.core.user.User; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public interface JwtService { + String toToken(User user); + + Optional getSubFromToken(String token); +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/user/FollowRelation.java b/.framework/java/backend/src/main/java/io/spring/core/user/FollowRelation.java new file mode 100644 index 00000000..7d7b5387 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/user/FollowRelation.java @@ -0,0 +1,17 @@ +package io.spring.core.user; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +public class FollowRelation { + private String userId; + private String targetId; + + public FollowRelation(String userId, String targetId) { + + this.userId = userId; + this.targetId = targetId; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/user/User.java b/.framework/java/backend/src/main/java/io/spring/core/user/User.java new file mode 100644 index 00000000..3044d503 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/user/User.java @@ -0,0 +1,50 @@ +package io.spring.core.user; + +import io.spring.Util; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = {"id"}) +public class User { + private String id; + private String email; + private String username; + private String password; + private String bio; + private String image; + + public User(String email, String username, String password, String bio, String image) { + this.id = UUID.randomUUID().toString(); + this.email = email; + this.username = username; + this.password = password; + this.bio = bio; + this.image = image; + } + + public void update(String email, String username, String password, String bio, String image) { + if (!Util.isEmpty(email)) { + this.email = email; + } + + if (!Util.isEmpty(username)) { + this.username = username; + } + + if (!Util.isEmpty(password)) { + this.password = password; + } + + if (!Util.isEmpty(bio)) { + this.bio = bio; + } + + if (!Util.isEmpty(image)) { + this.image = image; + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/core/user/UserRepository.java b/.framework/java/backend/src/main/java/io/spring/core/user/UserRepository.java new file mode 100644 index 00000000..f52c7725 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/user/UserRepository.java @@ -0,0 +1,21 @@ +package io.spring.core.user; + +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository { + void save(User user); + + Optional findById(String id); + + Optional findByUsername(String username); + + Optional findByEmail(String email); + + void saveRelation(FollowRelation followRelation); + + Optional findRelation(String userId, String targetId); + + void removeRelation(FollowRelation followRelation); +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/CommentDatafetcher.java b/.framework/java/backend/src/main/java/io/spring/graphql/CommentDatafetcher.java new file mode 100644 index 00000000..1102ba31 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/CommentDatafetcher.java @@ -0,0 +1,122 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import graphql.relay.DefaultConnectionCursor; +import graphql.relay.DefaultPageInfo; +import io.spring.application.CommentQueryService; +import io.spring.application.CursorPageParameter; +import io.spring.application.CursorPager; +import io.spring.application.CursorPager.Direction; +import io.spring.application.DateTimeCursor; +import io.spring.application.data.ItemData; +import io.spring.application.data.CommentData; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants.ITEM; +import io.spring.graphql.DgsConstants.COMMENTPAYLOAD; +import io.spring.graphql.types.Item; +import io.spring.graphql.types.Comment; +import io.spring.graphql.types.CommentEdge; +import io.spring.graphql.types.CommentsConnection; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import org.joda.time.format.ISODateTimeFormat; + +@DgsComponent +@AllArgsConstructor +public class CommentDatafetcher { + private CommentQueryService commentQueryService; + + @DgsData(parentType = COMMENTPAYLOAD.TYPE_NAME, field = COMMENTPAYLOAD.Comment) + public DataFetcherResult getComment(DgsDataFetchingEnvironment dfe) { + CommentData comment = dfe.getLocalContext(); + Comment commentResult = buildCommentResult(comment); + return DataFetcherResult.newResult() + .data(commentResult) + .localContext( + new HashMap() { + { + put(comment.getId(), comment); + } + }) + .build(); + } + + @DgsData(parentType = ITEM.TYPE_NAME, field = ITEM.Comments) + public DataFetcherResult itemComments( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + + if (first == null && last == null) { + throw new IllegalArgumentException("first ε’Œ last εΏ…ι‘»εͺε­˜εœ¨δΈ€δΈͺ"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + Item item = dfe.getSource(); + Map map = dfe.getLocalContext(); + ItemData itemData = map.get(item.getSlug()); + + CursorPager comments; + if (first != null) { + comments = + commentQueryService.findByItemIdWithCursor( + itemData.getId(), + current, + new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT)); + } else { + comments = + commentQueryService.findByItemIdWithCursor( + itemData.getId(), + current, + new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV)); + } + graphql.relay.PageInfo pageInfo = buildCommentPageInfo(comments); + CommentsConnection result = + CommentsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + comments.getData().stream() + .map( + a -> + CommentEdge.newBuilder() + .cursor(a.getCursor().toString()) + .node(buildCommentResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(result) + .localContext( + comments.getData().stream().collect(Collectors.toMap(CommentData::getId, c -> c))) + .build(); + } + + private DefaultPageInfo buildCommentPageInfo(CursorPager comments) { + return new DefaultPageInfo( + comments.getStartCursor() == null + ? null + : new DefaultConnectionCursor(comments.getStartCursor().toString()), + comments.getEndCursor() == null + ? null + : new DefaultConnectionCursor(comments.getEndCursor().toString()), + comments.hasPrevious(), + comments.hasNext()); + } + + private Comment buildCommentResult(CommentData comment) { + return Comment.newBuilder() + .id(comment.getId()) + .body(comment.getBody()) + .updatedAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())) + .createdAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())) + .build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/CommentMutation.java b/.framework/java/backend/src/main/java/io/spring/graphql/CommentMutation.java new file mode 100644 index 00000000..7bcbf995 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/CommentMutation.java @@ -0,0 +1,68 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.NoAuthorizationException; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.CommentQueryService; +import io.spring.application.data.CommentData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.service.AuthorizationService; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.exception.AuthenticationException; +import io.spring.graphql.types.CommentPayload; +import io.spring.graphql.types.DeletionStatus; +import lombok.AllArgsConstructor; + +@DgsComponent +@AllArgsConstructor +public class CommentMutation { + + private ItemRepository itemRepository; + private CommentRepository commentRepository; + private CommentQueryService commentQueryService; + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.AddComment) + public DataFetcherResult createComment( + @InputArgument("slug") String slug, @InputArgument("body") String body) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + Comment comment = new Comment(body, user.getId(), item.getId()); + commentRepository.save(comment); + CommentData commentData = + commentQueryService + .findById(comment.getId(), user) + .orElseThrow(ResourceNotFoundException::new); + return DataFetcherResult.newResult() + .localContext(commentData) + .data(CommentPayload.newBuilder().build()) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.DeleteComment) + public DeletionStatus removeComment( + @InputArgument("slug") String slug, @InputArgument("id") String commentId) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + return commentRepository + .findById(item.getId(), commentId) + .map( + comment -> { + if (!AuthorizationService.canWriteComment(user, item, comment)) { + throw new NoAuthorizationException(); + } + commentRepository.remove(comment); + return DeletionStatus.newBuilder().success(true).build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/ItemDatafetcher.java b/.framework/java/backend/src/main/java/io/spring/graphql/ItemDatafetcher.java new file mode 100644 index 00000000..faffa4ee --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/ItemDatafetcher.java @@ -0,0 +1,384 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import graphql.relay.DefaultConnectionCursor; +import graphql.relay.DefaultPageInfo; +import graphql.schema.DataFetchingEnvironment; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ItemQueryService; +import io.spring.application.CursorPageParameter; +import io.spring.application.CursorPager; +import io.spring.application.CursorPager.Direction; +import io.spring.application.DateTimeCursor; +import io.spring.application.data.ItemData; +import io.spring.application.data.CommentData; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.DgsConstants.ITEMPAYLOAD; +import io.spring.graphql.DgsConstants.COMMENT; +import io.spring.graphql.DgsConstants.PROFILE; +import io.spring.graphql.DgsConstants.QUERY; +import io.spring.graphql.types.Item; +import io.spring.graphql.types.ItemEdge; +import io.spring.graphql.types.ItemsConnection; +import io.spring.graphql.types.Profile; +import java.util.HashMap; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import org.joda.time.format.ISODateTimeFormat; + +@DgsComponent +@AllArgsConstructor +public class ItemDatafetcher { + + private ItemQueryService itemQueryService; + private UserRepository userRepository; + + @DgsQuery(field = QUERY.Feed) + public DataFetcherResult getFeed( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first ε’Œ last εΏ…ι‘»εͺε­˜εœ¨δΈ€δΈͺ"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + + CursorPager items; + if (first != null) { + items = + itemQueryService.findUserFeedWithCursor( + current, + new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT)); + } else { + items = + itemQueryService.findUserFeedWithCursor( + current, + new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV)); + } + graphql.relay.PageInfo pageInfo = buildItemPageInfo(items); + ItemsConnection itemsConnection = + ItemsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + items.getData().stream() + .map( + a -> + ItemEdge.newBuilder() + .cursor(a.getCursor().toString()) + .node(buildItemResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(itemsConnection) + .localContext( + items.getData().stream().collect(Collectors.toMap(ItemData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Feed) + public DataFetcherResult userFeed( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first ε’Œ last εΏ…ι‘»εͺε­˜εœ¨δΈ€δΈͺ"); + } + + Profile profile = dfe.getSource(); + User target = + userRepository + .findByUsername(profile.getUsername()) + .orElseThrow(ResourceNotFoundException::new); + + CursorPager items; + if (first != null) { + items = + itemQueryService.findUserFeedWithCursor( + target, + new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT)); + } else { + items = + itemQueryService.findUserFeedWithCursor( + target, + new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV)); + } + graphql.relay.PageInfo pageInfo = buildItemPageInfo(items); + ItemsConnection itemsConnection = + ItemsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + items.getData().stream() + .map( + a -> + ItemEdge.newBuilder() + .cursor(a.getCursor().toString()) + .node(buildItemResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(itemsConnection) + .localContext( + items.getData().stream().collect(Collectors.toMap(ItemData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Favorites) + public DataFetcherResult userFavorites( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first ε’Œ last εΏ…ι‘»εͺε­˜εœ¨δΈ€δΈͺ"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + Profile profile = dfe.getSource(); + + CursorPager items; + if (first != null) { + items = + itemQueryService.findRecentItemsWithCursor( + null, + null, + profile.getUsername(), + new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT), + current); + } else { + items = + itemQueryService.findRecentItemsWithCursor( + null, + null, + profile.getUsername(), + new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV), + current); + } + graphql.relay.PageInfo pageInfo = buildItemPageInfo(items); + + ItemsConnection itemsConnection = + ItemsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + items.getData().stream() + .map( + a -> + ItemEdge.newBuilder() + .cursor(a.getCursor().toString()) + .node(buildItemResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(itemsConnection) + .localContext( + items.getData().stream().collect(Collectors.toMap(ItemData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Items) + public DataFetcherResult userItems( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first ε’Œ last εΏ…ι‘»εͺε­˜εœ¨δΈ€δΈͺ"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + Profile profile = dfe.getSource(); + + CursorPager items; + if (first != null) { + items = + itemQueryService.findRecentItemsWithCursor( + null, + profile.getUsername(), + null, + new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT), + current); + } else { + items = + itemQueryService.findRecentItemsWithCursor( + null, + profile.getUsername(), + null, + new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV), + current); + } + graphql.relay.PageInfo pageInfo = buildItemPageInfo(items); + ItemsConnection itemsConnection = + ItemsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + items.getData().stream() + .map( + a -> + ItemEdge.newBuilder() + .cursor(a.getCursor().toString()) + .node(buildItemResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(itemsConnection) + .localContext( + items.getData().stream().collect(Collectors.toMap(ItemData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Items) + public DataFetcherResult getItems( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + @InputArgument("soldBy") String soldBy, + @InputArgument("favoritedBy") String favoritedBy, + @InputArgument("withTag") String withTag, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first ε’Œ last εΏ…ι‘»εͺε­˜εœ¨δΈ€δΈͺ"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + + CursorPager items; + if (first != null) { + items = + itemQueryService.findRecentItemsWithCursor( + withTag, + soldBy, + favoritedBy, + new CursorPageParameter<>(DateTimeCursor.parse(after), first, Direction.NEXT), + current); + } else { + items = + itemQueryService.findRecentItemsWithCursor( + withTag, + soldBy, + favoritedBy, + new CursorPageParameter<>(DateTimeCursor.parse(before), last, Direction.PREV), + current); + } + graphql.relay.PageInfo pageInfo = buildItemPageInfo(items); + ItemsConnection itemsConnection = + ItemsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + items.getData().stream() + .map( + a -> + ItemEdge.newBuilder() + .cursor(a.getCursor().toString()) + .node(buildItemResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(itemsConnection) + .localContext( + items.getData().stream().collect(Collectors.toMap(ItemData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = ITEMPAYLOAD.TYPE_NAME, field = ITEMPAYLOAD.Item) + public DataFetcherResult getItem(DataFetchingEnvironment dfe) { + io.spring.core.item.Item item = dfe.getLocalContext(); + + User current = SecurityUtil.getCurrentUser().orElse(null); + ItemData itemData = + itemQueryService + .findById(item.getId(), current) + .orElseThrow(ResourceNotFoundException::new); + Item itemResult = buildItemResult(itemData); + return DataFetcherResult.newResult() + .localContext( + new HashMap() { + { + put(itemData.getSlug(), itemData); + } + }) + .data(itemResult) + .build(); + } + + @DgsData(parentType = COMMENT.TYPE_NAME, field = COMMENT.Item) + public DataFetcherResult getCommentItem( + DataFetchingEnvironment dataFetchingEnvironment) { + CommentData comment = dataFetchingEnvironment.getLocalContext(); + User current = SecurityUtil.getCurrentUser().orElse(null); + ItemData itemData = + itemQueryService + .findById(comment.getItemId(), current) + .orElseThrow(ResourceNotFoundException::new); + Item itemResult = buildItemResult(itemData); + return DataFetcherResult.newResult() + .localContext( + new HashMap() { + { + put(itemData.getSlug(), itemData); + } + }) + .data(itemResult) + .build(); + } + + @DgsQuery(field = QUERY.Item) + public DataFetcherResult findItemBySlug(@InputArgument("slug") String slug) { + User current = SecurityUtil.getCurrentUser().orElse(null); + ItemData itemData = + itemQueryService.findBySlug(slug, current).orElseThrow(ResourceNotFoundException::new); + Item itemResult = buildItemResult(itemData); + return DataFetcherResult.newResult() + .localContext( + new HashMap() { + { + put(itemData.getSlug(), itemData); + } + }) + .data(itemResult) + .build(); + } + + private DefaultPageInfo buildItemPageInfo(CursorPager items) { + return new DefaultPageInfo( + items.getStartCursor() == null + ? null + : new DefaultConnectionCursor(items.getStartCursor().toString()), + items.getEndCursor() == null + ? null + : new DefaultConnectionCursor(items.getEndCursor().toString()), + items.hasPrevious(), + items.hasNext()); + } + + private Item buildItemResult(ItemData itemData) { + return Item.newBuilder() + .image(itemData.getImage()) + .createdAt(ISODateTimeFormat.dateTime().withZoneUTC().print(itemData.getCreatedAt())) + .description(itemData.getDescription()) + .favorited(itemData.isFavorited()) + .favoritesCount(itemData.getFavoritesCount()) + .slug(itemData.getSlug()) + .tagList(itemData.getTagList()) + .title(itemData.getTitle()) + .updatedAt(ISODateTimeFormat.dateTime().withZoneUTC().print(itemData.getUpdatedAt())) + .build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/ItemMutation.java b/.framework/java/backend/src/main/java/io/spring/graphql/ItemMutation.java new file mode 100644 index 00000000..de4c4ba3 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/ItemMutation.java @@ -0,0 +1,115 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsMutation; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.NoAuthorizationException; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.item.ItemCommandService; +import io.spring.application.item.NewItemParam; +import io.spring.application.item.UpdateItemParam; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.core.service.AuthorizationService; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.exception.AuthenticationException; +import io.spring.graphql.types.ItemPayload; +import io.spring.graphql.types.CreateItemInput; +import io.spring.graphql.types.DeletionStatus; +import io.spring.graphql.types.UpdateItemInput; +import java.util.Collections; +import lombok.AllArgsConstructor; + +@DgsComponent +@AllArgsConstructor +public class ItemMutation { + + private ItemCommandService itemCommandService; + private ItemFavoriteRepository itemFavoriteRepository; + private ItemRepository itemRepository; + + @DgsMutation(field = MUTATION.CreateItem) + public DataFetcherResult createItem( + @InputArgument("input") CreateItemInput input) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + NewItemParam newItemParam = + NewItemParam.builder() + .title(input.getTitle()) + .description(input.getDescription()) + .image(input.getImage()) + .tagList(input.getTagList() == null ? Collections.emptyList() : input.getTagList()) + .build(); + Item item = itemCommandService.createItem(newItemParam, user); + return DataFetcherResult.newResult() + .data(ItemPayload.newBuilder().build()) + .localContext(item) + .build(); + } + + @DgsMutation(field = MUTATION.UpdateItem) + public DataFetcherResult updateItem( + @InputArgument("slug") String slug, @InputArgument("changes") UpdateItemInput params) { + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + if (!AuthorizationService.canWriteItem(user, item)) { + throw new NoAuthorizationException(); + } + item = + itemCommandService.updateItem( + item, + new UpdateItemParam(params.getTitle(), params.getImage(), params.getDescription())); + return DataFetcherResult.newResult() + .data(ItemPayload.newBuilder().build()) + .localContext(item) + .build(); + } + + @DgsMutation(field = MUTATION.FavoriteItem) + public DataFetcherResult favoriteItem(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ItemFavorite itemFavorite = new ItemFavorite(item.getId(), user.getId()); + itemFavoriteRepository.save(itemFavorite); + return DataFetcherResult.newResult() + .data(ItemPayload.newBuilder().build()) + .localContext(item) + .build(); + } + + @DgsMutation(field = MUTATION.UnfavoriteItem) + public DataFetcherResult unfavoriteItem(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + itemFavoriteRepository + .find(item.getId(), user.getId()) + .ifPresent( + favorite -> { + itemFavoriteRepository.remove(favorite); + }); + return DataFetcherResult.newResult() + .data(ItemPayload.newBuilder().build()) + .localContext(item) + .build(); + } + + @DgsMutation(field = MUTATION.DeleteItem) + public DeletionStatus deleteItem(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Item item = + itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + + if (!AuthorizationService.canWriteItem(user, item)) { + throw new NoAuthorizationException(); + } + + itemRepository.remove(item); + return DeletionStatus.newBuilder().success(true).build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/MeDatafetcher.java b/.framework/java/backend/src/main/java/io/spring/graphql/MeDatafetcher.java new file mode 100644 index 00000000..93985967 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/MeDatafetcher.java @@ -0,0 +1,61 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import graphql.execution.DataFetcherResult; +import graphql.schema.DataFetchingEnvironment; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.UserQueryService; +import io.spring.application.data.UserData; +import io.spring.application.data.UserWithToken; +import io.spring.core.service.JwtService; +import io.spring.graphql.DgsConstants.QUERY; +import io.spring.graphql.DgsConstants.USERPAYLOAD; +import io.spring.graphql.types.User; +import lombok.AllArgsConstructor; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.RequestHeader; + +@DgsComponent +@AllArgsConstructor +public class MeDatafetcher { + private UserQueryService userQueryService; + private JwtService jwtService; + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Me) + public DataFetcherResult getMe( + @RequestHeader(value = "Authorization") String authorization, + DataFetchingEnvironment dataFetchingEnvironment) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return null; + } + io.spring.core.user.User user = (io.spring.core.user.User) authentication.getPrincipal(); + UserData userData = + userQueryService.findById(user.getId()).orElseThrow(ResourceNotFoundException::new); + UserWithToken userWithToken = new UserWithToken(userData, authorization.split(" ")[1]); + User result = + User.newBuilder() + .email(userWithToken.getEmail()) + .username(userWithToken.getUsername()) + .token(userWithToken.getToken()) + .build(); + return DataFetcherResult.newResult().data(result).localContext(user).build(); + } + + @DgsData(parentType = USERPAYLOAD.TYPE_NAME, field = USERPAYLOAD.User) + public DataFetcherResult getUserPayloadUser( + DataFetchingEnvironment dataFetchingEnvironment) { + io.spring.core.user.User user = dataFetchingEnvironment.getLocalContext(); + User result = + User.newBuilder() + .email(user.getEmail()) + .username(user.getUsername()) + .token(jwtService.toToken(user)) + .build(); + return DataFetcherResult.newResult().data(result).localContext(user).build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/ProfileDatafetcher.java b/.framework/java/backend/src/main/java/io/spring/graphql/ProfileDatafetcher.java new file mode 100644 index 00000000..0afc3f48 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/ProfileDatafetcher.java @@ -0,0 +1,71 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.schema.DataFetchingEnvironment; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ItemData; +import io.spring.application.data.CommentData; +import io.spring.application.data.ProfileData; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants.ITEM; +import io.spring.graphql.DgsConstants.COMMENT; +import io.spring.graphql.DgsConstants.QUERY; +import io.spring.graphql.DgsConstants.USER; +import io.spring.graphql.types.Item; +import io.spring.graphql.types.Comment; +import io.spring.graphql.types.Profile; +import io.spring.graphql.types.ProfilePayload; +import java.util.Map; +import lombok.AllArgsConstructor; + +@DgsComponent +@AllArgsConstructor +public class ProfileDatafetcher { + + private ProfileQueryService profileQueryService; + + @DgsData(parentType = USER.TYPE_NAME, field = USER.Profile) + public Profile getUserProfile(DataFetchingEnvironment dataFetchingEnvironment) { + User user = dataFetchingEnvironment.getLocalContext(); + String username = user.getUsername(); + return queryProfile(username); + } + + @DgsData(parentType = ITEM.TYPE_NAME, field = ITEM.Seller) + public Profile getSeller(DataFetchingEnvironment dataFetchingEnvironment) { + Map map = dataFetchingEnvironment.getLocalContext(); + Item item = dataFetchingEnvironment.getSource(); + return queryProfile(map.get(item.getSlug()).getProfileData().getUsername()); + } + + @DgsData(parentType = COMMENT.TYPE_NAME, field = COMMENT.Seller) + public Profile getCommentSeller(DataFetchingEnvironment dataFetchingEnvironment) { + Comment comment = dataFetchingEnvironment.getSource(); + Map map = dataFetchingEnvironment.getLocalContext(); + return queryProfile(map.get(comment.getId()).getProfileData().getUsername()); + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Profile) + public ProfilePayload queryProfile( + @InputArgument("username") String username, DataFetchingEnvironment dataFetchingEnvironment) { + Profile profile = queryProfile(dataFetchingEnvironment.getArgument("username")); + return ProfilePayload.newBuilder().profile(profile).build(); + } + + private Profile queryProfile(String username) { + User current = SecurityUtil.getCurrentUser().orElse(null); + ProfileData profileData = + profileQueryService + .findByUsername(username, current) + .orElseThrow(ResourceNotFoundException::new); + return Profile.newBuilder() + .username(profileData.getUsername()) + .bio(profileData.getBio()) + .image(profileData.getImage()) + .following(profileData.isFollowing()) + .build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/RelationMutation.java b/.framework/java/backend/src/main/java/io/spring/graphql/RelationMutation.java new file mode 100644 index 00000000..317b4fcc --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/RelationMutation.java @@ -0,0 +1,65 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ProfileData; +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.exception.AuthenticationException; +import io.spring.graphql.types.Profile; +import io.spring.graphql.types.ProfilePayload; +import lombok.AllArgsConstructor; + +@DgsComponent +@AllArgsConstructor +public class RelationMutation { + + private UserRepository userRepository; + private ProfileQueryService profileQueryService; + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.FollowUser) + public ProfilePayload follow(@InputArgument("username") String username) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + return userRepository + .findByUsername(username) + .map( + target -> { + FollowRelation followRelation = new FollowRelation(user.getId(), target.getId()); + userRepository.saveRelation(followRelation); + Profile profile = buildProfile(username, user); + return ProfilePayload.newBuilder().profile(profile).build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UnfollowUser) + public ProfilePayload unfollow(@InputArgument("username") String username) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + User target = + userRepository.findByUsername(username).orElseThrow(ResourceNotFoundException::new); + return userRepository + .findRelation(user.getId(), target.getId()) + .map( + relation -> { + userRepository.removeRelation(relation); + Profile profile = buildProfile(username, user); + return ProfilePayload.newBuilder().profile(profile).build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + private Profile buildProfile(@InputArgument("username") String username, User current) { + ProfileData profileData = profileQueryService.findByUsername(username, current).get(); + return Profile.newBuilder() + .username(profileData.getUsername()) + .bio(profileData.getBio()) + .image(profileData.getImage()) + .following(profileData.isFollowing()) + .build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/SecurityUtil.java b/.framework/java/backend/src/main/java/io/spring/graphql/SecurityUtil.java new file mode 100644 index 00000000..24b723b2 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/SecurityUtil.java @@ -0,0 +1,19 @@ +package io.spring.graphql; + +import io.spring.core.user.User; +import java.util.Optional; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + public static Optional getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return Optional.empty(); + } + io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); + return Optional.of(currentUser); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/TagDatafetcher.java b/.framework/java/backend/src/main/java/io/spring/graphql/TagDatafetcher.java new file mode 100644 index 00000000..6b70bf5f --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/TagDatafetcher.java @@ -0,0 +1,19 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import io.spring.application.TagsQueryService; +import io.spring.graphql.DgsConstants.QUERY; +import java.util.List; +import lombok.AllArgsConstructor; + +@DgsComponent +@AllArgsConstructor +public class TagDatafetcher { + private TagsQueryService tagsQueryService; + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Tags) + public List getTags() { + return tagsQueryService.allTags(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/UserMutation.java b/.framework/java/backend/src/main/java/io/spring/graphql/UserMutation.java new file mode 100644 index 00000000..581a5b7b --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/UserMutation.java @@ -0,0 +1,93 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.InvalidAuthenticationException; +import io.spring.application.user.RegisterParam; +import io.spring.application.user.UpdateUserCommand; +import io.spring.application.user.UpdateUserParam; +import io.spring.application.user.UserService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.exception.GraphQLCustomizeExceptionHandler; +import io.spring.graphql.types.CreateUserInput; +import io.spring.graphql.types.UpdateUserInput; +import io.spring.graphql.types.UserPayload; +import io.spring.graphql.types.UserResult; +import java.util.Optional; +import javax.validation.ConstraintViolationException; +import lombok.AllArgsConstructor; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@DgsComponent +@AllArgsConstructor +public class UserMutation { + + private UserRepository userRepository; + private PasswordEncoder encryptService; + private UserService userService; + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.CreateUser) + public DataFetcherResult createUser(@InputArgument("input") CreateUserInput input) { + RegisterParam registerParam = + new RegisterParam(input.getEmail(), input.getUsername(), input.getPassword()); + User user; + try { + user = userService.createUser(registerParam); + } catch (ConstraintViolationException cve) { + return DataFetcherResult.newResult() + .data(GraphQLCustomizeExceptionHandler.getErrorsAsData(cve)) + .build(); + } + + return DataFetcherResult.newResult() + .data(UserPayload.newBuilder().build()) + .localContext(user) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.Login) + public DataFetcherResult login( + @InputArgument("password") String password, @InputArgument("email") String email) { + Optional optional = userRepository.findByEmail(email); + if (optional.isPresent() && encryptService.matches(password, optional.get().getPassword())) { + return DataFetcherResult.newResult() + .data(UserPayload.newBuilder().build()) + .localContext(optional.get()) + .build(); + } else { + throw new InvalidAuthenticationException(); + } + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UpdateUser) + public DataFetcherResult updateUser( + @InputArgument("changes") UpdateUserInput updateUserInput) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return null; + } + io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); + UpdateUserParam param = + UpdateUserParam.builder() + .username(updateUserInput.getUsername()) + .email(updateUserInput.getEmail()) + .bio(updateUserInput.getBio()) + .password(updateUserInput.getPassword()) + .image(updateUserInput.getImage()) + .build(); + + userService.updateUser(new UpdateUserCommand(currentUser, param)); + return DataFetcherResult.newResult() + .data(UserPayload.newBuilder().build()) + .localContext(currentUser) + .build(); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/exception/AuthenticationException.java b/.framework/java/backend/src/main/java/io/spring/graphql/exception/AuthenticationException.java new file mode 100644 index 00000000..417029f7 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/exception/AuthenticationException.java @@ -0,0 +1,3 @@ +package io.spring.graphql.exception; + +public class AuthenticationException extends RuntimeException {} diff --git a/.framework/java/backend/src/main/java/io/spring/graphql/exception/GraphQLCustomizeExceptionHandler.java b/.framework/java/backend/src/main/java/io/spring/graphql/exception/GraphQLCustomizeExceptionHandler.java new file mode 100644 index 00000000..bf4768b3 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/graphql/exception/GraphQLCustomizeExceptionHandler.java @@ -0,0 +1,114 @@ +package io.spring.graphql.exception; + +import com.netflix.graphql.dgs.exceptions.DefaultDataFetcherExceptionHandler; +import com.netflix.graphql.types.errors.ErrorType; +import com.netflix.graphql.types.errors.TypedGraphQLError; +import graphql.GraphQLError; +import graphql.execution.DataFetcherExceptionHandler; +import graphql.execution.DataFetcherExceptionHandlerParameters; +import graphql.execution.DataFetcherExceptionHandlerResult; +import io.spring.api.exception.FieldErrorResource; +import io.spring.api.exception.InvalidAuthenticationException; +import io.spring.graphql.types.Error; +import io.spring.graphql.types.ErrorItem; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import org.springframework.stereotype.Component; + +@Component +public class GraphQLCustomizeExceptionHandler implements DataFetcherExceptionHandler { + + private final DefaultDataFetcherExceptionHandler defaultHandler = + new DefaultDataFetcherExceptionHandler(); + + @Override + public DataFetcherExceptionHandlerResult onException( + DataFetcherExceptionHandlerParameters handlerParameters) { + if (handlerParameters.getException() instanceof InvalidAuthenticationException) { + GraphQLError graphqlError = + TypedGraphQLError.newBuilder() + .errorType(ErrorType.UNAUTHENTICATED) + .message(handlerParameters.getException().getMessage()) + .path(handlerParameters.getPath()) + .build(); + return DataFetcherExceptionHandlerResult.newResult().error(graphqlError).build(); + } else if (handlerParameters.getException() instanceof ConstraintViolationException) { + List errors = new ArrayList<>(); + for (ConstraintViolation violation : + ((ConstraintViolationException) handlerParameters.getException()) + .getConstraintViolations()) { + FieldErrorResource fieldErrorResource = + new FieldErrorResource( + violation.getRootBeanClass().getName(), + getParam(violation.getPropertyPath().toString()), + violation + .getConstraintDescriptor() + .getAnnotation() + .annotationType() + .getSimpleName(), + violation.getMessage()); + errors.add(fieldErrorResource); + } + GraphQLError graphqlError = + TypedGraphQLError.newBadRequestBuilder() + .message(handlerParameters.getException().getMessage()) + .path(handlerParameters.getPath()) + .extensions(errorsToMap(errors)) + .build(); + return DataFetcherExceptionHandlerResult.newResult().error(graphqlError).build(); + } else { + return defaultHandler.onException(handlerParameters); + } + } + + public static Error getErrorsAsData(ConstraintViolationException cve) { + List errors = new ArrayList<>(); + for (ConstraintViolation violation : cve.getConstraintViolations()) { + FieldErrorResource fieldErrorResource = + new FieldErrorResource( + violation.getRootBeanClass().getName(), + getParam(violation.getPropertyPath().toString()), + violation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(), + violation.getMessage()); + errors.add(fieldErrorResource); + } + Map> errorMap = new HashMap<>(); + for (FieldErrorResource fieldErrorResource : errors) { + if (!errorMap.containsKey(fieldErrorResource.getField())) { + errorMap.put(fieldErrorResource.getField(), new ArrayList<>()); + } + errorMap.get(fieldErrorResource.getField()).add(fieldErrorResource.getMessage()); + } + List errorItems = + errorMap.entrySet().stream() + .map(kv -> ErrorItem.newBuilder().key(kv.getKey()).value(kv.getValue()).build()) + .collect(Collectors.toList()); + return Error.newBuilder().message("BAD_REQUEST").errors(errorItems).build(); + } + + private static String getParam(String s) { + String[] splits = s.split("\\."); + if (splits.length == 1) { + return s; + } else { + return String.join(".", Arrays.copyOfRange(splits, 2, splits.length)); + } + } + + private static Map errorsToMap(List errors) { + Map json = new HashMap<>(); + for (FieldErrorResource fieldErrorResource : errors) { + if (!json.containsKey(fieldErrorResource.getField())) { + json.put(fieldErrorResource.getField(), new ArrayList<>()); + } + ((List) json.get(fieldErrorResource.getField())).add(fieldErrorResource.getMessage()); + } + return json; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/DateTimeHandler.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/DateTimeHandler.java new file mode 100644 index 00000000..19323e56 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/DateTimeHandler.java @@ -0,0 +1,44 @@ +package io.spring.infrastructure.mybatis; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.TimeZone; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; +import org.joda.time.DateTime; + +@MappedTypes(DateTime.class) +public class DateTimeHandler implements TypeHandler { + + private static final Calendar UTC_CALENDAR = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + + @Override + public void setParameter(PreparedStatement ps, int i, DateTime parameter, JdbcType jdbcType) + throws SQLException { + ps.setTimestamp( + i, parameter != null ? new Timestamp(parameter.getMillis()) : null, UTC_CALENDAR); + } + + @Override + public DateTime getResult(ResultSet rs, String columnName) throws SQLException { + Timestamp timestamp = rs.getTimestamp(columnName, UTC_CALENDAR); + return timestamp != null ? new DateTime(timestamp.getTime()) : null; + } + + @Override + public DateTime getResult(ResultSet rs, int columnIndex) throws SQLException { + Timestamp timestamp = rs.getTimestamp(columnIndex, UTC_CALENDAR); + return timestamp != null ? new DateTime(timestamp.getTime()) : null; + } + + @Override + public DateTime getResult(CallableStatement cs, int columnIndex) throws SQLException { + Timestamp ts = cs.getTimestamp(columnIndex, UTC_CALENDAR); + return ts != null ? new DateTime(ts.getTime()) : null; + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java new file mode 100644 index 00000000..5b7c3cc1 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java @@ -0,0 +1,14 @@ +package io.spring.infrastructure.mybatis.mapper; + +import io.spring.core.comment.Comment; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CommentMapper { + void insert(@Param("comment") Comment comment); + + Comment findById(@Param("itemId") String itemId, @Param("id") String id); + + void delete(@Param("id") String id); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemFavoriteMapper.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemFavoriteMapper.java new file mode 100644 index 00000000..4ff92dcd --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemFavoriteMapper.java @@ -0,0 +1,14 @@ +package io.spring.infrastructure.mybatis.mapper; + +import io.spring.core.favorite.ItemFavorite; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ItemFavoriteMapper { + ItemFavorite find(@Param("itemId") String itemId, @Param("userId") String userId); + + void insert(@Param("itemFavorite") ItemFavorite itemFavorite); + + void delete(@Param("favorite") ItemFavorite favorite); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemMapper.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemMapper.java new file mode 100644 index 00000000..4d114865 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemMapper.java @@ -0,0 +1,25 @@ +package io.spring.infrastructure.mybatis.mapper; + +import io.spring.core.item.Item; +import io.spring.core.item.Tag; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ItemMapper { + void insert(@Param("item") Item item); + + Item findById(@Param("id") String id); + + Tag findTag(@Param("tagName") String tagName); + + void insertTag(@Param("tag") Tag tag); + + void insertItemTagRelation(@Param("itemId") String itemId, @Param("tagId") String tagId); + + Item findBySlug(@Param("slug") String slug); + + void update(@Param("item") Item item); + + void delete(@Param("id") String id); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java new file mode 100644 index 00000000..54f36c76 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java @@ -0,0 +1,25 @@ +package io.spring.infrastructure.mybatis.mapper; + +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface UserMapper { + void insert(@Param("user") User user); + + User findByUsername(@Param("username") String username); + + User findByEmail(@Param("email") String email); + + User findById(@Param("id") String id); + + void update(@Param("user") User user); + + FollowRelation findRelation(@Param("userId") String userId, @Param("targetId") String targetId); + + void saveRelation(@Param("followRelation") FollowRelation followRelation); + + void deleteRelation(@Param("followRelation") FollowRelation followRelation); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/CommentReadService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/CommentReadService.java new file mode 100644 index 00000000..2c27b7c7 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/CommentReadService.java @@ -0,0 +1,18 @@ +package io.spring.infrastructure.mybatis.readservice; + +import io.spring.application.CursorPageParameter; +import io.spring.application.data.CommentData; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.joda.time.DateTime; + +@Mapper +public interface CommentReadService { + CommentData findById(@Param("id") String id); + + List findByItemId(@Param("itemId") String itemId); + + List findByItemIdWithCursor( + @Param("itemId") String itemId, @Param("page") CursorPageParameter page); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemFavoritesReadService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemFavoritesReadService.java new file mode 100644 index 00000000..c913a230 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemFavoritesReadService.java @@ -0,0 +1,19 @@ +package io.spring.infrastructure.mybatis.readservice; + +import io.spring.application.data.ItemFavoriteCount; +import io.spring.core.user.User; +import java.util.List; +import java.util.Set; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ItemFavoritesReadService { + boolean isUserFavorite(@Param("userId") String userId, @Param("itemId") String itemId); + + int itemFavoriteCount(@Param("itemId") String itemId); + + List itemsFavoriteCount(@Param("ids") List ids); + + Set userFavorites(@Param("ids") List ids, @Param("currentUser") User currentUser); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemReadService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemReadService.java new file mode 100644 index 00000000..e8198f9f --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemReadService.java @@ -0,0 +1,42 @@ +package io.spring.infrastructure.mybatis.readservice; + +import io.spring.application.CursorPageParameter; +import io.spring.application.Page; +import io.spring.application.data.ItemData; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ItemReadService { + ItemData findById(@Param("id") String id); + + ItemData findBySlug(@Param("slug") String slug); + + List queryItems( + @Param("tag") String tag, + @Param("seller") String seller, + @Param("favoritedBy") String favoritedBy, + @Param("page") Page page); + + int countItem( + @Param("tag") String tag, + @Param("seller") String seller, + @Param("favoritedBy") String favoritedBy); + + List findItems(@Param("itemIds") List itemIds); + + List findItemsOfSellers( + @Param("sellers") List authors, @Param("page") Page page); + + List findItemsOfSellersWithCursor( + @Param("sellers") List authors, @Param("page") CursorPageParameter page); + + int countFeedSize(@Param("sellers") List sellers); + + List findItemsWithCursor( + @Param("tag") String tag, + @Param("seller") String seller, + @Param("favoritedBy") String favoritedBy, + @Param("page") CursorPageParameter page); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/TagReadService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/TagReadService.java new file mode 100644 index 00000000..87376874 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/TagReadService.java @@ -0,0 +1,9 @@ +package io.spring.infrastructure.mybatis.readservice; + +import java.util.List; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TagReadService { + List all(); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserReadService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserReadService.java new file mode 100644 index 00000000..ae25a48d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserReadService.java @@ -0,0 +1,13 @@ +package io.spring.infrastructure.mybatis.readservice; + +import io.spring.application.data.UserData; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface UserReadService { + + UserData findByUsername(@Param("username") String username); + + UserData findById(@Param("id") String id); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserRelationshipQueryService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserRelationshipQueryService.java new file mode 100644 index 00000000..4a8b2304 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserRelationshipQueryService.java @@ -0,0 +1,16 @@ +package io.spring.infrastructure.mybatis.readservice; + +import java.util.List; +import java.util.Set; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface UserRelationshipQueryService { + boolean isUserFollowing( + @Param("userId") String userId, @Param("anotherUserId") String anotherUserId); + + Set followingSellers(@Param("userId") String userId, @Param("ids") List ids); + + List followedUsers(@Param("userId") String userId); +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisCommentRepository.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisCommentRepository.java new file mode 100644 index 00000000..e0d33e3e --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisCommentRepository.java @@ -0,0 +1,33 @@ +package io.spring.infrastructure.repository; + +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.infrastructure.mybatis.mapper.CommentMapper; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class MyBatisCommentRepository implements CommentRepository { + private CommentMapper commentMapper; + + @Autowired + public MyBatisCommentRepository(CommentMapper commentMapper) { + this.commentMapper = commentMapper; + } + + @Override + public void save(Comment comment) { + commentMapper.insert(comment); + } + + @Override + public Optional findById(String itemId, String id) { + return Optional.ofNullable(commentMapper.findById(itemId, id)); + } + + @Override + public void remove(Comment comment) { + commentMapper.delete(comment.getId()); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemFavoriteRepository.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemFavoriteRepository.java new file mode 100644 index 00000000..138bc996 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemFavoriteRepository.java @@ -0,0 +1,35 @@ +package io.spring.infrastructure.repository; + +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.infrastructure.mybatis.mapper.ItemFavoriteMapper; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class MyBatisItemFavoriteRepository implements ItemFavoriteRepository { + private ItemFavoriteMapper mapper; + + @Autowired + public MyBatisItemFavoriteRepository(ItemFavoriteMapper mapper) { + this.mapper = mapper; + } + + @Override + public void save(ItemFavorite itemFavorite) { + if (mapper.find(itemFavorite.getItemId(), itemFavorite.getUserId()) == null) { + mapper.insert(itemFavorite); + } + } + + @Override + public Optional find(String itemId, String userId) { + return Optional.ofNullable(mapper.find(itemId, userId)); + } + + @Override + public void remove(ItemFavorite favorite) { + mapper.delete(favorite); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemRepository.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemRepository.java new file mode 100644 index 00000000..8b46eaee --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemRepository.java @@ -0,0 +1,57 @@ +package io.spring.infrastructure.repository; + +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.item.Tag; +import io.spring.infrastructure.mybatis.mapper.ItemMapper; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class MyBatisItemRepository implements ItemRepository { + private ItemMapper itemMapper; + + public MyBatisItemRepository(ItemMapper itemMapper) { + this.itemMapper = itemMapper; + } + + @Override + @Transactional + public void save(Item item) { + if (itemMapper.findById(item.getId()) == null) { + createNew(item); + } else { + itemMapper.update(item); + } + } + + private void createNew(Item item) { + for (Tag tag : item.getTags()) { + Tag targetTag = + Optional.ofNullable(itemMapper.findTag(tag.getName())) + .orElseGet( + () -> { + itemMapper.insertTag(tag); + return tag; + }); + itemMapper.insertItemTagRelation(item.getId(), targetTag.getId()); + } + itemMapper.insert(item); + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(itemMapper.findById(id)); + } + + @Override + public Optional findBySlug(String slug) { + return Optional.ofNullable(itemMapper.findBySlug(slug)); + } + + @Override + public void remove(Item item) { + itemMapper.delete(item.getId()); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java new file mode 100644 index 00000000..3c24dd5f --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java @@ -0,0 +1,60 @@ +package io.spring.infrastructure.repository; + +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.mybatis.mapper.UserMapper; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class MyBatisUserRepository implements UserRepository { + private final UserMapper userMapper; + + @Autowired + public MyBatisUserRepository(UserMapper userMapper) { + this.userMapper = userMapper; + } + + @Override + public void save(User user) { + if (userMapper.findById(user.getId()) == null) { + userMapper.insert(user); + } else { + userMapper.update(user); + } + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(userMapper.findById(id)); + } + + @Override + public Optional findByUsername(String username) { + return Optional.ofNullable(userMapper.findByUsername(username)); + } + + @Override + public Optional findByEmail(String email) { + return Optional.ofNullable(userMapper.findByEmail(email)); + } + + @Override + public void saveRelation(FollowRelation followRelation) { + if (!findRelation(followRelation.getUserId(), followRelation.getTargetId()).isPresent()) { + userMapper.saveRelation(followRelation); + } + } + + @Override + public Optional findRelation(String userId, String targetId) { + return Optional.ofNullable(userMapper.findRelation(userId, targetId)); + } + + @Override + public void removeRelation(FollowRelation followRelation) { + userMapper.deleteRelation(followRelation); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/infrastructure/service/DefaultJwtService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/service/DefaultJwtService.java new file mode 100644 index 00000000..515d6610 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/service/DefaultJwtService.java @@ -0,0 +1,54 @@ +package io.spring.infrastructure.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import java.util.Date; +import java.util.Optional; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class DefaultJwtService implements JwtService { + private final SecretKey signingKey; + private final SignatureAlgorithm signatureAlgorithm; + private int sessionTime; + + @Autowired + public DefaultJwtService( + @Value("${jwt.secret}") String secret, @Value("${jwt.sessionTime}") int sessionTime) { + this.sessionTime = sessionTime; + signatureAlgorithm = SignatureAlgorithm.HS512; + this.signingKey = new SecretKeySpec(secret.getBytes(), signatureAlgorithm.getJcaName()); + } + + @Override + public String toToken(User user) { + return Jwts.builder() + .setSubject(user.getId()) + .setExpiration(expireTimeFromNow()) + .signWith(signingKey) + .compact(); + } + + @Override + public Optional getSubFromToken(String token) { + try { + Jws claimsJws = + Jwts.parserBuilder().setSigningKey(signingKey).build().parseClaimsJws(token); + return Optional.ofNullable(claimsJws.getBody().getSubject()); + } catch (Exception e) { + return Optional.empty(); + } + } + + private Date expireTimeFromNow() { + return new Date(System.currentTimeMillis() + sessionTime * 1000L); + } +} diff --git a/.framework/java/backend/src/main/resources/application-test.properties b/.framework/java/backend/src/main/resources/application-test.properties new file mode 100644 index 00000000..0902f5cb --- /dev/null +++ b/.framework/java/backend/src/main/resources/application-test.properties @@ -0,0 +1 @@ +spring.datasource.url=jdbc:sqlite::memory: \ No newline at end of file diff --git a/.framework/java/backend/src/main/resources/application.properties b/.framework/java/backend/src/main/resources/application.properties new file mode 100644 index 00000000..366fa911 --- /dev/null +++ b/.framework/java/backend/src/main/resources/application.properties @@ -0,0 +1,30 @@ +# spring.datasource.url=jdbc:sqlite:dev.db +# spring.datasource.driver-class-name=org.sqlite.JDBC +# spring.datasource.username= +# spring.datasource.password= + +#jdbc:postgresql://postgres:@postgres-java:5432/anythink-market +spring.datasource.url=jdbc:postgresql://postgres-java:5432/anythink-market +spring.datasource.username=postgres +spring.datasource.password= +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jackson.deserialization.UNWRAP_ROOT_VALUE=true + + +image.default=https://static.productionready.io/images/smiley-cyrus.jpg + +jwt.secret=nRvyYC4soFxBdZ-F-5Nnzz5USXstR1YylsTd-mA0aKtI9HUlriGrtkf-TiuDapkLiUCogO3JOK7kwZisrHp6wA +jwt.sessionTime=86400 + +mybatis.configuration.cache-enabled=true +mybatis.configuration.default-statement-timeout=3000 +mybatis.configuration.map-underscore-to-camel-case=true +mybatis.configuration.use-generated-keys=true +mybatis.type-handlers-package=io.spring.infrastructure.mybatis +mybatis.mapper-locations=mapper/*.xml + +logging.level.io.spring.infrastructure.mybatis.readservice.ItemReadService=DEBUG +logging.level.io.spring.infrastructure.mybatis.mapper=DEBUG + +server.port=3000 +server.servlet.contextPath=/api diff --git a/.framework/java/backend/src/main/resources/db/migration/V1__create_tables.sql b/.framework/java/backend/src/main/resources/db/migration/V1__create_tables.sql new file mode 100644 index 00000000..bc25314a --- /dev/null +++ b/.framework/java/backend/src/main/resources/db/migration/V1__create_tables.sql @@ -0,0 +1,49 @@ +create table users ( + id varchar(255) primary key, + username varchar(255) UNIQUE, + password varchar(255), + email varchar(255) UNIQUE, + bio text, + image varchar(511) +); + +create table items ( + id varchar(255) primary key, + seller_id varchar(255), + slug varchar(255) UNIQUE, + title varchar(255), + description text, + image text, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +create table item_favorites ( + item_id varchar(255) not null, + user_id varchar(255) not null, + primary key(item_id, user_id) +); + +create table follows ( + user_id varchar(255) not null, + follow_id varchar(255) not null +); + +create table tags ( + id varchar(255) primary key, + name varchar(255) not null +); + +create table item_tags ( + item_id varchar(255) not null, + tag_id varchar(255) not null +); + +create table comments ( + id varchar(255) primary key, + body text, + item_id varchar(255), + user_id varchar(255), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/.framework/java/backend/src/main/resources/mapper/CommentMapper.xml b/.framework/java/backend/src/main/resources/mapper/CommentMapper.xml new file mode 100644 index 00000000..4f112826 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/CommentMapper.xml @@ -0,0 +1,35 @@ + + + + + insert into comments(id, body, user_id, item_id, created_at, updated_at) + values ( + #{comment.id}, + #{comment.body}, + #{comment.userId}, + #{comment.itemId}, + #{comment.createdAt}, + #{comment.createdAt} + ) + + + delete from comments where id = #{id} + + + + + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/CommentReadService.xml b/.framework/java/backend/src/main/resources/mapper/CommentReadService.xml new file mode 100644 index 00000000..4cf8cedf --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/CommentReadService.xml @@ -0,0 +1,42 @@ + + + + + SELECT + C.id commentId, + C.body commentBody, + C.created_at commentCreatedAt, + C.item_id commentItemId, + + from comments C + left join users U + on C.user_id = U.id + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/ItemFavoriteMapper.xml b/.framework/java/backend/src/main/resources/mapper/ItemFavoriteMapper.xml new file mode 100644 index 00000000..5fbba910 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/ItemFavoriteMapper.xml @@ -0,0 +1,21 @@ + + + + + insert into item_favorites (item_id, user_id) values (#{itemFavorite.itemId}, #{itemFavorite.userId}) + + + delete from item_favorites where item_id = #{favorite.itemId} and user_id = #{favorite.userId} + + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/ItemFavoritesReadService.xml b/.framework/java/backend/src/main/resources/mapper/ItemFavoritesReadService.xml new file mode 100644 index 00000000..16a673fd --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/ItemFavoritesReadService.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/ItemMapper.xml b/.framework/java/backend/src/main/resources/mapper/ItemMapper.xml new file mode 100644 index 00000000..202198f2 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/ItemMapper.xml @@ -0,0 +1,82 @@ + + + + + insert into items(id, slug, title, description, image, seller_id, created_at, updated_at) + values( + #{item.id}, + #{item.slug}, + #{item.title}, + #{item.description}, + #{item.image}, + #{item.sellerId}, + #{item.createdAt}, + #{item.updatedAt}) + + + insert into tags (id, name) values (#{tag.id}, #{tag.name}) + + + insert into item_tags (item_id, tag_id) values(#{itemId}, #{tagId}) + + + update items + + title = #{item.title}, + slug = #{item.slug}, + description = #{item.description}, + image = #{item.image} + + where id = #{item.id} + + + delete from items where id = #{id} + + + select + A.id itemId, + A.slug itemSlug, + A.title itemTitle, + A.description itemDescription, + A.image itemImage, + A.seller_id itemSellerId, + A.created_at itemCreatedAt, + A.updated_at itemUpdatedAt, + T.id tagId, + T.name tagName + from items A + left join item_tags AT on A.id = AT.item_id + left join tags T on T.id = AT.tag_id + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/ItemReadService.xml b/.framework/java/backend/src/main/resources/mapper/ItemReadService.xml new file mode 100644 index 00000000..e6599e13 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/ItemReadService.xml @@ -0,0 +1,162 @@ + + + + + U.id userId, + U.username userUsername, + U.bio userBio, + U.image userImage + + + select + A.id itemId, + A.slug itemSlug, + A.title itemTitle, + A.description itemDescription, + A.image itemImage, + A.created_at itemCreatedAt, + A.updated_at itemUpdatedAt, + T.name tagName, + + from + items A + left join item_tags AT on A.id = AT.item_id + left join tags T on T.id = AT.tag_id + left join users U on U.id = A.seller_id + + + select + DISTINCT(A.id) itemId, A.created_at + from + items A + left join item_tags AT on A.id = AT.item_id + left join tags T on T.id = AT.tag_id + left join item_favorites AF on AF.item_id = A.id + left join users AU on AU.id = A.seller_id + left join users AFU on AFU.id = AF.user_id + + + + + + + + + + + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/TagReadService.xml b/.framework/java/backend/src/main/resources/mapper/TagReadService.xml new file mode 100644 index 00000000..0e8ceef8 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/TagReadService.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.framework/java/backend/src/main/resources/mapper/TransferData.xml b/.framework/java/backend/src/main/resources/mapper/TransferData.xml new file mode 100644 index 00000000..45bcd8d2 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/TransferData.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.framework/java/backend/src/main/resources/mapper/UserMapper.xml b/.framework/java/backend/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 00000000..08e89b22 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,61 @@ + + + + + insert into users (id, username, email, password, bio, image) values( + #{user.id}, + #{user.username}, + #{user.email}, + #{user.password}, + #{user.bio}, + #{user.image} + ) + + + insert into follows(user_id, follow_id) values (#{followRelation.userId}, #{followRelation.targetId}) + + + update users + + username = #{user.username}, + email = #{user.email}, + password = #{user.password}, + bio = #{user.bio}, + image = #{user.image} + + where id = #{user.id} + + + delete from follows where user_id = #{followRelation.userId} and follow_id = #{followRelation.targetId} + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.framework/java/backend/src/main/resources/mapper/UserReadService.xml b/.framework/java/backend/src/main/resources/mapper/UserReadService.xml new file mode 100644 index 00000000..edf53c14 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/UserReadService.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.framework/java/backend/src/main/resources/mapper/UserRelationshipQueryService.xml b/.framework/java/backend/src/main/resources/mapper/UserRelationshipQueryService.xml new file mode 100644 index 00000000..8bed7548 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/UserRelationshipQueryService.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/.framework/java/backend/src/main/resources/schema/schema.graphqls b/.framework/java/backend/src/main/resources/schema/schema.graphqls new file mode 100644 index 00000000..7a73b4d8 --- /dev/null +++ b/.framework/java/backend/src/main/resources/schema/schema.graphqls @@ -0,0 +1,177 @@ +# Build the schema. +type Query { + item(slug: String!): Item + items( + first: Int, + after: String, + last: Int, + before: String, + soldBy: String + favoritedBy: String + withTag: String + ): ItemsConnection + me: User + feed(first: Int, after: String, last: Int, before: String): ItemsConnection + profile(username: String!): ProfilePayload + tags: [String] +} + +union UserResult = UserPayload | Error + +type Mutation { + ### User & Profile + createUser(input: CreateUserInput): UserResult + login(password: String!, email: String!): UserPayload + updateUser(changes: UpdateUserInput!): UserPayload + followUser(username: String!): ProfilePayload + unfollowUser(username: String!): ProfilePayload + + ### Item + createItem(input: CreateItemInput!): ItemPayload + updateItem(slug: String!, changes: UpdateItemInput!): ItemPayload + favoriteItem(slug: String!): ItemPayload + unfavoriteItem(slug: String!): ItemPayload + deleteItem(slug: String!): DeletionStatus + + ### Comment + addComment(slug: String!, body: String!): CommentPayload + deleteComment(slug: String!, id: ID!): DeletionStatus +} + +schema { + query: Query + mutation: Mutation +} + +### Items +type Item { + seller: Profile! + comments(first: Int, after: String, last: Int, before: String): CommentsConnection + createdAt: String! + description: String! + favorited: Boolean! + favoritesCount: Int! + image: String! + slug: String! + tagList: [String], + title: String! + updatedAt: String! +} + +type ItemEdge { + cursor: String! + node: Item +} + +type ItemsConnection { + edges: [ItemEdge] + pageInfo: PageInfo! +} + +### Comments +type Comment { + id: ID! + seller: Profile! + item: Item! + body: String! + createdAt: String! + updatedAt: String! +} + +type CommentEdge { + cursor: String! + node: Comment +} + +type CommentsConnection { + edges: [CommentEdge] + pageInfo: PageInfo! +} + +type DeletionStatus { + success: Boolean! +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +### Profile +type Profile { + username: String! + bio: String + following: Boolean! + image: String + items(first: Int, after: String, last: Int, before: String): ItemsConnection + favorites(first: Int, after: String, last: Int, before: String): ItemsConnection + feed(first: Int, after: String, last: Int, before: String): ItemsConnection +} + +### User +type User { + email: String! + profile: Profile! + token: String! + username: String! +} + +### Error +type Error { + message: String + errors: [ErrorItem!] +} + +type ErrorItem { + key: String! + value: [String!]! +} + +## Mutations + +# Input types. +input UpdateItemInput { + description: String + image: String + title: String +} + +input CreateItemInput { + description: String! + image: String! + tagList: [String] + title: String! +} + +type ItemPayload { + item: Item +} + +type CommentPayload { + comment: Comment +} + +input CreateUserInput { + email: String! + username: String! + password: String! +} + +input UpdateUserInput { + email: String + username: String + password: String + image: String + bio: String +} + +type UserPayload { + user: User +} + +type ProfilePayload { + profile: Profile +} + diff --git a/.framework/java/backend/src/test/java/io/spring/AythinkMarketApplicationTests.java b/.framework/java/backend/src/test/java/io/spring/AythinkMarketApplicationTests.java new file mode 100644 index 00000000..af9d938a --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/AythinkMarketApplicationTests.java @@ -0,0 +1,11 @@ +package io.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class AnythinkMarketApplicationTests { + + @Test + public void contextLoads() {} +} diff --git a/.framework/java/backend/src/test/java/io/spring/TestHelper.java b/.framework/java/backend/src/test/java/io/spring/TestHelper.java new file mode 100644 index 00000000..3dd29f2f --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/TestHelper.java @@ -0,0 +1,42 @@ +package io.spring; + +import io.spring.application.data.ItemData; +import io.spring.application.data.ProfileData; +import io.spring.core.item.Item; +import io.spring.core.user.User; +import java.util.ArrayList; +import java.util.Arrays; +import org.joda.time.DateTime; + +public class TestHelper { + public static ItemData itemDataFixture(String seed, User user) { + DateTime now = new DateTime(); + return new ItemData( + seed + "id", + "title-" + seed, + "title " + seed, + "desc " + seed, + "image" + seed, + false, + 0, + now, + now, + new ArrayList<>(), + new ProfileData(user.getId(), user.getUsername(), user.getBio(), user.getImage(), false)); + } + + public static ItemData getItemDataFromItemAndUser(Item item, User user) { + return new ItemData( + item.getId(), + item.getSlug(), + item.getTitle(), + item.getDescription(), + item.getImage(), + false, + 0, + item.getCreatedAt(), + item.getUpdatedAt(), + Arrays.asList("joda"), + new ProfileData(user.getId(), user.getUsername(), user.getBio(), user.getImage(), false)); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/CommentsApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/CommentsApiTest.java new file mode 100644 index 00000000..21b93f2d --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/CommentsApiTest.java @@ -0,0 +1,165 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.CommentQueryService; +import io.spring.application.data.CommentData; +import io.spring.application.data.ProfileData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.user.User; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(CommentsApi.class) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class CommentsApiTest extends TestWithCurrentUser { + + @MockBean private ItemRepository itemRepository; + + @MockBean private CommentRepository commentRepository; + @MockBean private CommentQueryService commentQueryService; + + private Item item; + private CommentData commentData; + private Comment comment; + @Autowired private MockMvc mvc; + + @BeforeEach + public void setUp() throws Exception { + RestAssuredMockMvc.mockMvc(mvc); + super.setUp(); + item = new Item("title", "desc", "image", Arrays.asList("test", "java"), user.getId()); + when(itemRepository.findBySlug(eq(item.getSlug()))).thenReturn(Optional.of(item)); + comment = new Comment("comment", user.getId(), item.getId()); + commentData = + new CommentData( + comment.getId(), + comment.getBody(), + comment.getItemId(), + comment.getCreatedAt(), + comment.getCreatedAt(), + new ProfileData( + user.getId(), user.getUsername(), user.getBio(), user.getImage(), false)); + } + + @Test + public void should_create_comment_success() throws Exception { + Map param = + new HashMap() { + { + put( + "comment", + new HashMap() { + { + put("body", "comment content"); + } + }); + } + }; + + when(commentQueryService.findById(anyString(), eq(user))).thenReturn(Optional.of(commentData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .post("/items/{slug}/comments", item.getSlug()) + .then() + .statusCode(201) + .body("comment.body", equalTo(commentData.getBody())); + } + + @Test + public void should_get_422_with_empty_body() throws Exception { + Map param = + new HashMap() { + { + put( + "comment", + new HashMap() { + { + put("body", ""); + } + }); + } + }; + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .post("/items/{slug}/comments", item.getSlug()) + .then() + .statusCode(422) + .body("errors.body[0]", equalTo("can't be empty")); + } + + @Test + public void should_get_comments_of_item_success() throws Exception { + when(commentQueryService.findByItemId(anyString(), eq(null))) + .thenReturn(Arrays.asList(commentData)); + RestAssuredMockMvc.when() + .get("/items/{slug}/comments", item.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("comments[0].id", equalTo(commentData.getId())); + } + + @Test + public void should_delete_comment_success() throws Exception { + when(commentRepository.findById(eq(item.getId()), eq(comment.getId()))) + .thenReturn(Optional.of(comment)); + + given() + .header("Authorization", "Token " + token) + .when() + .delete("/items/{slug}/comments/{id}", item.getSlug(), comment.getId()) + .then() + .statusCode(204); + } + + @Test + public void should_get_403_if_not_seller_of_item_or_user_of_comment_when_delete_comment() + throws Exception { + User anotherUser = new User("other@example.com", "other", "123", "", ""); + when(userRepository.findByUsername(eq(anotherUser.getUsername()))) + .thenReturn(Optional.of(anotherUser)); + when(jwtService.getSubFromToken(any())).thenReturn(Optional.of(anotherUser.getId())); + when(userRepository.findById(eq(anotherUser.getId()))) + .thenReturn(Optional.ofNullable(anotherUser)); + + when(commentRepository.findById(eq(item.getId()), eq(comment.getId()))) + .thenReturn(Optional.of(comment)); + String token = jwtService.toToken(anotherUser); + when(userRepository.findById(eq(anotherUser.getId()))).thenReturn(Optional.of(anotherUser)); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/items/{slug}/comments/{id}", item.getSlug(), comment.getId()) + .then() + .statusCode(403); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/CurrentUserApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/CurrentUserApiTest.java new file mode 100644 index 00000000..08e8ece2 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/CurrentUserApiTest.java @@ -0,0 +1,179 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.UserQueryService; +import io.spring.application.user.UserService; +import io.spring.core.user.User; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(CurrentUserApi.class) +@Import({ + WebSecurityConfig.class, + JacksonCustomizations.class, + UserService.class, + ValidationAutoConfiguration.class, + BCryptPasswordEncoder.class +}) +public class CurrentUserApiTest extends TestWithCurrentUser { + + @Autowired private MockMvc mvc; + + @MockBean private UserQueryService userQueryService; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + } + + @Test + public void should_get_current_user_with_token() throws Exception { + when(userQueryService.findById(any())).thenReturn(Optional.of(userData)); + + given() + .header("Authorization", "Token " + token) + .contentType("application/json") + .when() + .get("/user") + .then() + .statusCode(200) + .body("user.email", equalTo(email)) + .body("user.username", equalTo(username)) + .body("user.bio", equalTo("")) + .body("user.image", equalTo(defaultAvatar)) + .body("user.token", equalTo(token)); + } + + @Test + public void should_get_401_without_token() throws Exception { + given().contentType("application/json").when().get("/user").then().statusCode(401); + } + + @Test + public void should_get_401_with_invalid_token() throws Exception { + String invalidToken = "asdfasd"; + when(jwtService.getSubFromToken(eq(invalidToken))).thenReturn(Optional.empty()); + given() + .contentType("application/json") + .header("Authorization", "Token " + invalidToken) + .when() + .get("/user") + .then() + .statusCode(401); + } + + @Test + public void should_update_current_user_profile() throws Exception { + String newEmail = "newemail@example.com"; + String newBio = "updated"; + String newUsername = "newusernamee"; + + Map param = + new HashMap() { + { + put( + "user", + new HashMap() { + { + put("email", newEmail); + put("bio", newBio); + put("username", newUsername); + } + }); + } + }; + + when(userRepository.findByUsername(eq(newUsername))).thenReturn(Optional.empty()); + when(userRepository.findByEmail(eq(newEmail))).thenReturn(Optional.empty()); + + when(userQueryService.findById(eq(user.getId()))).thenReturn(Optional.of(userData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .put("/user") + .then() + .statusCode(200); + } + + @Test + public void should_get_error_if_email_exists_when_update_user_profile() throws Exception { + String newEmail = "newemail@example.com"; + String newBio = "updated"; + String newUsername = "newusernamee"; + + Map param = prepareUpdateParam(newEmail, newBio, newUsername); + + when(userRepository.findByEmail(eq(newEmail))) + .thenReturn(Optional.of(new User(newEmail, "username", "123", "", ""))); + when(userRepository.findByUsername(eq(newUsername))).thenReturn(Optional.empty()); + + when(userQueryService.findById(eq(user.getId()))).thenReturn(Optional.of(userData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .put("/user") + .prettyPeek() + .then() + .statusCode(422) + .body("errors.email[0]", equalTo("email already exist")); + } + + private HashMap prepareUpdateParam( + final String newEmail, final String newBio, final String newUsername) { + return new HashMap() { + { + put( + "user", + new HashMap() { + { + put("email", newEmail); + put("bio", newBio); + put("username", newUsername); + } + }); + } + }; + } + + @Test + public void should_get_401_if_not_login() throws Exception { + given() + .contentType("application/json") + .body( + new HashMap() { + { + put("user", new HashMap()); + } + }) + .when() + .put("/user") + .then() + .statusCode(401); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/ItemApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/ItemApiTest.java new file mode 100644 index 00000000..e329dd82 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ItemApiTest.java @@ -0,0 +1,226 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.TestHelper; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ItemQueryService; +import io.spring.application.item.ItemCommandService; +import io.spring.application.data.ItemData; +import io.spring.application.data.ProfileData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.user.User; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.joda.time.DateTime; +import org.joda.time.format.ISODateTimeFormat; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({ItemApi.class}) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ItemApiTest extends TestWithCurrentUser { + @Autowired private MockMvc mvc; + + @MockBean private ItemQueryService itemQueryService; + + @MockBean private ItemRepository itemRepository; + + @MockBean ItemCommandService itemCommandService; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + } + + @Test + public void should_read_item_success() throws Exception { + String slug = "test-new-item"; + DateTime time = new DateTime(); + Item item = + new Item( + "Test New Item", + "Desc", + "Body", + "Image", + Arrays.asList("java", "spring", "jpg"), + user.getId(), + time); + ItemData itemData = TestHelper.getItemDataFromItemAndUser(item, user); + + when(itemQueryService.findBySlug(eq(slug), eq(null))).thenReturn(Optional.of(itemData)); + + RestAssuredMockMvc.when() + .get("/items/{slug}", slug) + .then() + .statusCode(200) + .body("item.slug", equalTo(slug)) + .body("item.image", equalTo(itemData.getImage())) + .body("item.createdAt", equalTo(ISODateTimeFormat.dateTime().withZoneUTC().print(time))); + } + + @Test + public void should_404_if_item_not_found() throws Exception { + when(itemQueryService.findBySlug(anyString(), any())).thenReturn(Optional.empty()); + RestAssuredMockMvc.when().get("/items/not-exists").then().statusCode(404); + } + + @Test + public void should_update_item_content_success() throws Exception { + List tagList = Arrays.asList("java", "spring", "jpg"); + + Item originalItem = + new Item("old title", "old description", "old image", tagList, user.getId()); + + Item updatedItem = + new Item("new title", "new description", "old image", tagList, user.getId()); + + Map updateParam = + prepareUpdateParam( + updatedItem.getTitle(), updatedItem.getImage(), updatedItem.getDescription()); + + ItemData updatedItemData = + TestHelper.getItemDataFromItemAndUser(updatedItem, user); + + when(itemRepository.findBySlug(eq(originalItem.getSlug()))) + .thenReturn(Optional.of(originalItem)); + when(itemCommandService.updateItem(eq(originalItem), any())) + .thenReturn(updatedItem); + when(itemQueryService.findBySlug(eq(updatedItem.getSlug()), eq(user))) + .thenReturn(Optional.of(updatedItemData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(updateParam) + .when() + .put("/items/{slug}", originalItem.getSlug()) + .then() + .statusCode(200) + .body("item.slug", equalTo(updatedItemData.getSlug())); + } + + @Test + public void should_get_403_if_not_user_to_update_item() throws Exception { + String title = "new-title"; + String image = "new image"; + String description = "new description"; + Map updateParam = prepareUpdateParam(title, image, description); + + User anotherUser = new User("test@test.com", "test", "123123", "", ""); + + Item item = + new Item( + title, description, image, Arrays.asList("java", "spring", "jpg"), anotherUser.getId()); + + DateTime time = new DateTime(); + ItemData itemData = + new ItemData( + item.getId(), + item.getSlug(), + item.getTitle(), + item.getDescription(), + item.getImage(), + false, + 0, + time, + time, + Arrays.asList("joda"), + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false)); + + when(itemRepository.findBySlug(eq(item.getSlug()))).thenReturn(Optional.of(item)); + when(itemQueryService.findBySlug(eq(item.getSlug()), eq(user))) + .thenReturn(Optional.of(itemData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(updateParam) + .when() + .put("/items/{slug}", item.getSlug()) + .then() + .statusCode(403); + } + + @Test + public void should_delete_item_success() throws Exception { + String title = "title"; + String image = "image"; + String description = "description"; + + Item item = + new Item(title, description, image, Arrays.asList("java", "spring", "jpg"), user.getId()); + when(itemRepository.findBySlug(eq(item.getSlug()))).thenReturn(Optional.of(item)); + + given() + .header("Authorization", "Token " + token) + .when() + .delete("/items/{slug}", item.getSlug()) + .then() + .statusCode(204); + + verify(itemRepository).remove(eq(item)); + } + + @Test + public void should_403_if_not_author_delete_item() throws Exception { + String title = "new-title"; + String image = "new image"; + String description = "new description"; + + User anotherUser = new User("test@test.com", "test", "123123", "", ""); + + Item item = + new Item( + title, description, image, Arrays.asList("java", "spring", "jpg"), anotherUser.getId()); + + when(itemRepository.findBySlug(eq(item.getSlug()))).thenReturn(Optional.of(item)); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/items/{slug}", item.getSlug()) + .then() + .statusCode(403); + } + + private HashMap prepareUpdateParam( + final String title, final String image, final String description) { + return new HashMap() { + { + put( + "item", + new HashMap() { + { + put("title", title); + put("image", image); + put("description", description); + } + }); + } + }; + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/ItemFavoriteApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/ItemFavoriteApiTest.java new file mode 100644 index 00000000..7f7ec5a7 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ItemFavoriteApiTest.java @@ -0,0 +1,103 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ItemQueryService; +import io.spring.application.data.ItemData; +import io.spring.application.data.ProfileData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.item.Tag; +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.core.user.User; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ItemFavoriteApi.class) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ItemFavoriteApiTest extends TestWithCurrentUser { + @Autowired private MockMvc mvc; + + @MockBean private ItemFavoriteRepository itemFavoriteRepository; + + @MockBean private ItemRepository itemRepository; + + @MockBean private ItemQueryService itemQueryService; + + private Item item; + + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + User anotherUser = new User("other@test.com", "other", "123", "", ""); + item = new Item("title", "desc", "image", Arrays.asList("java"), anotherUser.getId()); + when(itemRepository.findBySlug(eq(item.getSlug()))).thenReturn(Optional.of(item)); + ItemData itemData = + new ItemData( + item.getId(), + item.getSlug(), + item.getTitle(), + item.getDescription(), + item.getImage(), + true, + 1, + item.getCreatedAt(), + item.getUpdatedAt(), + item.getTags().stream().map(Tag::getName).collect(Collectors.toList()), + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false)); + when(itemQueryService.findBySlug(eq(itemData.getSlug()), eq(user))) + .thenReturn(Optional.of(itemData)); + } + + @Test + public void should_favorite_an_item_success() throws Exception { + given() + .header("Authorization", "Token " + token) + .when() + .post("/items/{slug}/favorite", item.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("item.id", equalTo(item.getId())); + + verify(itemFavoriteRepository).save(any()); + } + + @Test + public void should_unfavorite_an_item_success() throws Exception { + when(itemFavoriteRepository.find(eq(item.getId()), eq(user.getId()))) + .thenReturn(Optional.of(new ItemFavorite(item.getId(), user.getId()))); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/items/{slug}/favorite", item.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("item.id", equalTo(item.getId())); + verify(itemFavoriteRepository).remove(new ItemFavorite(item.getId(), user.getId())); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/ItemsApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/ItemsApiTest.java new file mode 100644 index 00000000..2cef0673 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ItemsApiTest.java @@ -0,0 +1,173 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static java.util.Arrays.asList; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ItemQueryService; +import io.spring.application.item.ItemCommandService; +import io.spring.application.data.ItemData; +import io.spring.application.data.ProfileData; +import io.spring.core.item.Item; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.joda.time.DateTime; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({ItemsApi.class}) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ItemsApiTest extends TestWithCurrentUser { + @Autowired private MockMvc mvc; + + @MockBean private ItemQueryService itemQueryService; + + @MockBean private ItemCommandService itemCommandService; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + } + + @Test + public void should_create_item_success() throws Exception { + String title = "How to train your dragon"; + String slug = "how-to-train-your-dragon"; + String description = "Ever wonder how?"; + String image = "Another image"; + List tagList = asList("reactjs", "angularjs", "dragons"); + Map param = prepareParam(title, description, image, tagList); + + ItemData itemData = + new ItemData( + "123", + slug, + title, + description, + image, + false, + 0, + new DateTime(), + new DateTime(), + tagList, + new ProfileData("userid", user.getUsername(), user.getBio(), user.getImage(), false)); + + when(itemCommandService.createItem(any(), any())) + .thenReturn(new Item(title, description, image, tagList, user.getId())); + + when(itemQueryService.findBySlug(eq(Item.toSlug(title)), any())) + .thenReturn(Optional.empty()); + + when(itemQueryService.findById(any(), any())).thenReturn(Optional.of(itemData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .post("/items") + .then() + .statusCode(200) + .body("item.title", equalTo(title)) + .body("item.favorited", equalTo(false)) + .body("item.body", equalTo(body)) + .body("item.favoritesCount", equalTo(0)) + .body("item.seller.username", equalTo(user.getUsername())) + .body("item.seller.id", equalTo(null)); + + verify(itemCommandService).createItem(any(), any()); + } + + @Test + public void should_get_error_message_with_wrong_parameter() throws Exception { + String title = "How to train your dragon"; + String description = "Ever wonder how?"; + String image = ""; + String[] tagList = {"reactjs", "angularjs", "dragons"}; + Map param = prepareParam(title, description, image, asList(tagList)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .post("/items") + .prettyPeek() + .then() + .statusCode(422) + .body("errors.body[0]", equalTo("can't be empty")); + } + + @Test + public void should_get_error_message_with_duplicated_title() { + String title = "How to train your dragon"; + String slug = "how-to-train-your-dragon"; + String description = "Ever wonder how?"; + String image = "Image URL"; + String[] tagList = {"reactjs", "angularjs", "dragons"}; + Map param = prepareParam(title, description, image, asList(tagList)); + + ItemData itemData = + new ItemData( + "123", + slug, + title, + description, + body, + false, + 0, + new DateTime(), + new DateTime(), + asList(tagList), + new ProfileData("userid", user.getUsername(), user.getBio(), user.getImage(), false)); + + when(itemQueryService.findBySlug(eq(Item.toSlug(title)), any())) + .thenReturn(Optional.of(itemData)); + + when(itemQueryService.findById(any(), any())).thenReturn(Optional.of(itemData)); + + given() + .contentType("application/json") + .header("Authorization", "Token " + token) + .body(param) + .when() + .post("/items") + .prettyPeek() + .then() + .statusCode(422); + } + + private HashMap prepareParam( + final String title, final String description, final String image, final List tagList) { + return new HashMap() { + { + put( + "item", + new HashMap() { + { + put("title", title); + put("description", description); + put("image", image); + put("tagList", tagList); + } + }); + } + }; + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/ListItemApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/ListItemApiTest.java new file mode 100644 index 00000000..705c9888 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ListItemApiTest.java @@ -0,0 +1,75 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static io.spring.TestHelper.itemDataFixture; +import static java.util.Arrays.asList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ItemQueryService; +import io.spring.application.Page; +import io.spring.application.item.ItemCommandService; +import io.spring.application.data.ItemDataList; +import io.spring.core.item.ItemRepository; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ItemsApi.class) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ListItemApiTest extends TestWithCurrentUser { + @MockBean private ItemRepository itemRepository; + + @MockBean private ItemQueryService itemQueryService; + + @MockBean private ItemCommandService itemCommandService; + + @Autowired private MockMvc mvc; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + } + + @Test + public void should_get_default_item_list() throws Exception { + ItemDataList itemDataList = + new ItemDataList( + asList(itemDataFixture("1", user), itemDataFixture("2", user)), 2); + when(itemQueryService.findRecentItems( + eq(null), eq(null), eq(null), eq(new Page(0, 20)), eq(null))) + .thenReturn(itemDataList); + RestAssuredMockMvc.when().get("/items").prettyPeek().then().statusCode(200); + } + + @Test + public void should_get_feeds_401_without_login() throws Exception { + RestAssuredMockMvc.when().get("/items/feed").prettyPeek().then().statusCode(401); + } + + @Test + public void should_get_feeds_success() throws Exception { + ItemDataList itemDataList = + new ItemDataList( + asList(itemDataFixture("1", user), itemDataFixture("2", user)), 2); + when(itemQueryService.findUserFeed(eq(user), eq(new Page(0, 20)))) + .thenReturn(itemDataList); + + given() + .header("Authorization", "Token " + token) + .when() + .get("/items/feed") + .prettyPeek() + .then() + .statusCode(200); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/ProfileApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/ProfileApiTest.java new file mode 100644 index 00000000..f32091ec --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ProfileApiTest.java @@ -0,0 +1,96 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ProfileData; +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import java.util.Optional; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ProfileApi.class) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ProfileApiTest extends TestWithCurrentUser { + private User anotherUser; + + @Autowired private MockMvc mvc; + + @MockBean private ProfileQueryService profileQueryService; + + private ProfileData profileData; + + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + anotherUser = new User("username@test.com", "username", "123", "", ""); + profileData = + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false); + when(userRepository.findByUsername(eq(anotherUser.getUsername()))) + .thenReturn(Optional.of(anotherUser)); + } + + @Test + public void should_get_user_profile_success() throws Exception { + when(profileQueryService.findByUsername(eq(profileData.getUsername()), eq(null))) + .thenReturn(Optional.of(profileData)); + RestAssuredMockMvc.when() + .get("/profiles/{username}", profileData.getUsername()) + .prettyPeek() + .then() + .statusCode(200) + .body("profile.username", equalTo(profileData.getUsername())); + } + + @Test + public void should_follow_user_success() throws Exception { + when(profileQueryService.findByUsername(eq(profileData.getUsername()), eq(user))) + .thenReturn(Optional.of(profileData)); + given() + .header("Authorization", "Token " + token) + .when() + .post("/profiles/{username}/follow", anotherUser.getUsername()) + .prettyPeek() + .then() + .statusCode(200); + verify(userRepository).saveRelation(new FollowRelation(user.getId(), anotherUser.getId())); + } + + @Test + public void should_unfollow_user_success() throws Exception { + FollowRelation followRelation = new FollowRelation(user.getId(), anotherUser.getId()); + when(userRepository.findRelation(eq(user.getId()), eq(anotherUser.getId()))) + .thenReturn(Optional.of(followRelation)); + when(profileQueryService.findByUsername(eq(profileData.getUsername()), eq(user))) + .thenReturn(Optional.of(profileData)); + + given() + .header("Authorization", "Token " + token) + .when() + .delete("/profiles/{username}/follow", anotherUser.getUsername()) + .prettyPeek() + .then() + .statusCode(200); + + verify(userRepository).removeRelation(eq(followRelation)); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/TestWithCurrentUser.java b/.framework/java/backend/src/test/java/io/spring/api/TestWithCurrentUser.java new file mode 100644 index 00000000..7d3b104b --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/TestWithCurrentUser.java @@ -0,0 +1,49 @@ +package io.spring.api; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import io.spring.application.data.UserData; +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.mybatis.readservice.UserReadService; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.boot.test.mock.mockito.MockBean; + +abstract class TestWithCurrentUser { + @MockBean protected UserRepository userRepository; + + @MockBean protected UserReadService userReadService; + + protected User user; + protected UserData userData; + protected String token; + protected String email; + protected String username; + protected String defaultAvatar; + + @MockBean protected JwtService jwtService; + + protected void userFixture() { + email = "john@jacob.com"; + username = "johnjacob"; + defaultAvatar = "https://static.productionready.io/images/smiley-cyrus.jpg"; + + user = new User(email, username, "123", "", defaultAvatar); + when(userRepository.findByUsername(eq(username))).thenReturn(Optional.of(user)); + when(userRepository.findById(eq(user.getId()))).thenReturn(Optional.of(user)); + + userData = new UserData(user.getId(), email, username, "", defaultAvatar); + when(userReadService.findById(eq(user.getId()))).thenReturn(userData); + + token = "token"; + when(jwtService.getSubFromToken(eq(token))).thenReturn(Optional.of(user.getId())); + } + + @BeforeEach + public void setUp() throws Exception { + userFixture(); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/api/UsersApiTest.java b/.framework/java/backend/src/test/java/io/spring/api/UsersApiTest.java new file mode 100644 index 00000000..9074f2ed --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/UsersApiTest.java @@ -0,0 +1,271 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.UserQueryService; +import io.spring.application.data.UserData; +import io.spring.application.user.UserService; +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.mybatis.readservice.UserReadService; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(UsersApi.class) +@Import({ + WebSecurityConfig.class, + UserQueryService.class, + BCryptPasswordEncoder.class, + JacksonCustomizations.class +}) +public class UsersApiTest { + @Autowired private MockMvc mvc; + + @MockBean private UserRepository userRepository; + + @MockBean private JwtService jwtService; + + @MockBean private UserReadService userReadService; + + @MockBean private UserService userService; + + @Autowired private PasswordEncoder passwordEncoder; + + private String defaultAvatar; + + @BeforeEach + public void setUp() throws Exception { + RestAssuredMockMvc.mockMvc(mvc); + defaultAvatar = "https://static.productionready.io/images/smiley-cyrus.jpg"; + } + + @Test + public void should_create_user_success() throws Exception { + String email = "john@jacob.com"; + String username = "johnjacob"; + + when(jwtService.toToken(any())).thenReturn("123"); + User user = new User(email, username, "123", "", defaultAvatar); + UserData userData = new UserData(user.getId(), email, username, "", defaultAvatar); + when(userReadService.findById(any())).thenReturn(userData); + + when(userService.createUser(any())).thenReturn(user); + + when(userRepository.findByUsername(eq(username))).thenReturn(Optional.empty()); + when(userRepository.findByEmail(eq(email))).thenReturn(Optional.empty()); + + Map param = prepareRegisterParameter(email, username); + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users") + .then() + .statusCode(201) + .body("user.email", equalTo(email)) + .body("user.username", equalTo(username)) + .body("user.bio", equalTo("")) + .body("user.image", equalTo(defaultAvatar)) + .body("user.token", equalTo("123")); + + verify(userService).createUser(any()); + } + + @Test + public void should_show_error_message_for_blank_username() throws Exception { + + String email = "john@jacob.com"; + String username = ""; + + Map param = prepareRegisterParameter(email, username); + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users") + .prettyPeek() + .then() + .statusCode(422) + .body("errors.username[0]", equalTo("can't be empty")); + } + + @Test + public void should_show_error_message_for_invalid_email() throws Exception { + String email = "johnxjacob.com"; + String username = "johnjacob"; + + Map param = prepareRegisterParameter(email, username); + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users") + .prettyPeek() + .then() + .statusCode(422) + .body("errors.email[0]", equalTo("should be an email")); + } + + @Test + public void should_show_error_for_duplicated_username() throws Exception { + String email = "john@jacob.com"; + String username = "johnjacob"; + + when(userRepository.findByUsername(eq(username))) + .thenReturn(Optional.of(new User(email, username, "123", "bio", ""))); + when(userRepository.findByEmail(any())).thenReturn(Optional.empty()); + + Map param = prepareRegisterParameter(email, username); + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users") + .prettyPeek() + .then() + .statusCode(422) + .body("errors.username[0]", equalTo("duplicated username")); + } + + @Test + public void should_show_error_for_duplicated_email() throws Exception { + String email = "john@jacob.com"; + String username = "johnjacob2"; + + when(userRepository.findByEmail(eq(email))) + .thenReturn(Optional.of(new User(email, username, "123", "bio", ""))); + + when(userRepository.findByUsername(eq(username))).thenReturn(Optional.empty()); + + Map param = prepareRegisterParameter(email, username); + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users") + .then() + .statusCode(422) + .body("errors.email[0]", equalTo("duplicated email")); + } + + private HashMap prepareRegisterParameter( + final String email, final String username) { + return new HashMap() { + { + put( + "user", + new HashMap() { + { + put("email", email); + put("password", "johnnyjacob"); + put("username", username); + } + }); + } + }; + } + + @Test + public void should_login_success() throws Exception { + String email = "john@jacob.com"; + String username = "johnjacob2"; + String password = "123"; + + User user = new User(email, username, passwordEncoder.encode(password), "", defaultAvatar); + UserData userData = new UserData("123", email, username, "", defaultAvatar); + + when(userRepository.findByEmail(eq(email))).thenReturn(Optional.of(user)); + when(userReadService.findByUsername(eq(username))).thenReturn(userData); + when(userReadService.findById(eq(user.getId()))).thenReturn(userData); + when(jwtService.toToken(any())).thenReturn("123"); + + Map param = + new HashMap() { + { + put( + "user", + new HashMap() { + { + put("email", email); + put("password", password); + } + }); + } + }; + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users/login") + .then() + .statusCode(200) + .body("user.email", equalTo(email)) + .body("user.username", equalTo(username)) + .body("user.bio", equalTo("")) + .body("user.image", equalTo(defaultAvatar)) + .body("user.token", equalTo("123")); + ; + } + + @Test + public void should_fail_login_with_wrong_password() throws Exception { + String email = "john@jacob.com"; + String username = "johnjacob2"; + String password = "123"; + + User user = new User(email, username, password, "", defaultAvatar); + UserData userData = new UserData(user.getId(), email, username, "", defaultAvatar); + + when(userRepository.findByEmail(eq(email))).thenReturn(Optional.of(user)); + when(userReadService.findByUsername(eq(username))).thenReturn(userData); + + Map param = + new HashMap() { + { + put( + "user", + new HashMap() { + { + put("email", email); + put("password", "123123"); + } + }); + } + }; + + given() + .contentType("application/json") + .body(param) + .when() + .post("/users/login") + .prettyPeek() + .then() + .statusCode(422) + .body("message", equalTo("invalid email or password")); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/application/comment/CommentQueryServiceTest.java b/.framework/java/backend/src/test/java/io/spring/application/comment/CommentQueryServiceTest.java new file mode 100644 index 00000000..53d9842a --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/application/comment/CommentQueryServiceTest.java @@ -0,0 +1,76 @@ +package io.spring.application.comment; + +import io.spring.application.CommentQueryService; +import io.spring.application.data.CommentData; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisItemRepository; +import io.spring.infrastructure.repository.MyBatisCommentRepository; +import io.spring.infrastructure.repository.MyBatisUserRepository; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({ + MyBatisCommentRepository.class, + MyBatisUserRepository.class, + CommentQueryService.class, + MyBatisItemRepository.class +}) +public class CommentQueryServiceTest extends DbTestBase { + @Autowired private CommentRepository commentRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private CommentQueryService commentQueryService; + + @Autowired private ItemRepository itemRepository; + + private User user; + + @BeforeEach + public void setUp() { + user = new User("aisensiy@test.com", "aisensiy", "123", "", ""); + userRepository.save(user); + } + + @Test + public void should_read_comment_success() { + Comment comment = new Comment("content", user.getId(), "123"); + commentRepository.save(comment); + + Optional optional = commentQueryService.findById(comment.getId(), user); + Assertions.assertTrue(optional.isPresent()); + CommentData commentData = optional.get(); + Assertions.assertEquals(commentData.getProfileData().getUsername(), user.getUsername()); + } + + @Test + public void should_read_comments_of_item() { + Item item = new Item("title", "desc", "image", Arrays.asList("java"), user.getId()); + itemRepository.save(item); + + User user2 = new User("user2@email.com", "user2", "123", "", ""); + userRepository.save(user2); + userRepository.saveRelation(new FollowRelation(user.getId(), user2.getId())); + + Comment comment1 = new Comment("content1", user.getId(), item.getId()); + commentRepository.save(comment1); + Comment comment2 = new Comment("content2", user2.getId(), item.getId()); + commentRepository.save(comment2); + + List comments = commentQueryService.findByItemId(item.getId(), user); + Assertions.assertEquals(comments.size(), 2); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/application/item/ItemQueryServiceTest.java b/.framework/java/backend/src/test/java/io/spring/application/item/ItemQueryServiceTest.java new file mode 100644 index 00000000..ba8ebeaa --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/application/item/ItemQueryServiceTest.java @@ -0,0 +1,230 @@ +package io.spring.application.item; + +import io.spring.application.ItemQueryService; +import io.spring.application.CursorPageParameter; +import io.spring.application.CursorPager; +import io.spring.application.CursorPager.Direction; +import io.spring.application.DateTimeCursor; +import io.spring.application.Page; +import io.spring.application.data.ItemData; +import io.spring.application.data.ItemDataList; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisItemFavoriteRepository; +import io.spring.infrastructure.repository.MyBatisItemRepository; +import io.spring.infrastructure.repository.MyBatisUserRepository; +import java.util.Arrays; +import java.util.Optional; +import org.joda.time.DateTime; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({ + ItemQueryService.class, + MyBatisUserRepository.class, + MyBatisItemRepository.class, + MyBatisItemFavoriteRepository.class +}) +public class ItemQueryServiceTest extends DbTestBase { + @Autowired private ItemQueryService queryService; + + @Autowired private ItemRepository itemRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private ItemFavoriteRepository itemFavoriteRepository; + + private User user; + private Item item; + + @BeforeEach + public void setUp() { + user = new User("aisensiy@gmail.com", "aisensiy", "123", "", ""); + userRepository.save(user); + item = + new Item( + "test", "desc", "image", Arrays.asList("java", "spring"), user.getId(), new DateTime()); + itemRepository.save(item); + } + + @Test + public void should_fetch_item_success() { + Optional optional = queryService.findById(item.getId(), user); + Assertions.assertTrue(optional.isPresent()); + + ItemData fetched = optional.get(); + Assertions.assertEquals(fetched.getFavoritesCount(), 0); + Assertions.assertFalse(fetched.isFavorited()); + Assertions.assertNotNull(fetched.getCreatedAt()); + Assertions.assertNotNull(fetched.getUpdatedAt()); + Assertions.assertTrue(fetched.getTagList().contains("java")); + } + + @Test + public void should_get_item_with_right_favorite_and_favorite_count() { + User anotherUser = new User("other@test.com", "other", "123", "", ""); + userRepository.save(anotherUser); + itemFavoriteRepository.save(new ItemFavorite(item.getId(), anotherUser.getId())); + + Optional optional = queryService.findById(item.getId(), anotherUser); + Assertions.assertTrue(optional.isPresent()); + + ItemData itemData = optional.get(); + Assertions.assertEquals(itemData.getFavoritesCount(), 1); + Assertions.assertTrue(itemData.isFavorited()); + } + + @Test + public void should_get_default_item_list() { + Item anotherItem = + new Item( + "new item", + "desc", + "image", + Arrays.asList("test"), + user.getId(), + new DateTime().minusHours(1)); + itemRepository.save(anotherItem); + + ItemDataList recentItems = + queryService.findRecentItems(null, null, null, new Page(), user); + Assertions.assertEquals(recentItems.getCount(), 2); + Assertions.assertEquals(recentItems.getItemDatas().size(), 2); + Assertions.assertEquals(recentItems.getItemDatas().get(0).getId(), item.getId()); + + ItemDataList nodata = + queryService.findRecentItems(null, null, null, new Page(2, 10), user); + Assertions.assertEquals(nodata.getCount(), 2); + Assertions.assertEquals(nodata.getItemDatas().size(), 0); + } + + @Test + public void should_get_default_item_list_by_cursor() { + Item anotherItem = + new Item( + "new item", + "desc", + "image", + Arrays.asList("test"), + user.getId(), + new DateTime().minusHours(1)); + itemRepository.save(anotherItem); + + CursorPager recentItems = + queryService.findRecentItemsWithCursor( + null, null, null, new CursorPageParameter<>(null, 20, Direction.NEXT), user); + Assertions.assertEquals(recentItems.getData().size(), 2); + Assertions.assertEquals(recentItems.getData().get(0).getId(), item.getId()); + + CursorPager nodata = + queryService.findRecentItemsWithCursor( + null, + null, + null, + new CursorPageParameter( + DateTimeCursor.parse(recentItems.getEndCursor().toString()), 20, Direction.NEXT), + user); + Assertions.assertEquals(nodata.getData().size(), 0); + Assertions.assertEquals(nodata.getStartCursor(), null); + + CursorPager prevItems = + queryService.findRecentItemsWithCursor( + null, null, null, new CursorPageParameter<>(null, 20, Direction.PREV), user); + Assertions.assertEquals(prevItems.getData().size(), 2); + } + + @Test + public void should_query_item_by_seller() { + User anotherUser = new User("other@email.com", "other", "123", "", ""); + userRepository.save(anotherUser); + + Item anotherItem = + new Item("new item", "desc", "image", Arrays.asList("test"), anotherUser.getId()); + itemRepository.save(anotherItem); + + ItemDataList recentItems = + queryService.findRecentItems(null, user.getUsername(), null, new Page(), user); + Assertions.assertEquals(recentItems.getItemDatas().size(), 1); + Assertions.assertEquals(recentItems.getCount(), 1); + } + + @Test + public void should_query_item_by_favorite() { + User anotherUser = new User("other@email.com", "other", "123", "", ""); + userRepository.save(anotherUser); + + Item anotherItem = + new Item("new item", "desc", "image", Arrays.asList("test"), anotherUser.getId()); + itemRepository.save(anotherItem); + + ItemFavorite itemFavorite = new ItemFavorite(item.getId(), anotherUser.getId()); + itemFavoriteRepository.save(itemFavorite); + + ItemDataList recentItems = + queryService.findRecentItems( + null, null, anotherUser.getUsername(), new Page(), anotherUser); + Assertions.assertEquals(recentItems.getItemDatas().size(), 1); + Assertions.assertEquals(recentItems.getCount(), 1); + ItemData itemData = recentItems.getItemDatas().get(0); + Assertions.assertEquals(itemData.getId(), item.getId()); + Assertions.assertEquals(itemData.getFavoritesCount(), 1); + Assertions.assertTrue(itemData.isFavorited()); + } + + @Test + public void should_query_item_by_tag() { + Item anotherItem = + new Item("new item", "desc", "image", Arrays.asList("test"), user.getId()); + itemRepository.save(anotherItem); + + ItemDataList recentItems = + queryService.findRecentItems("spring", null, null, new Page(), user); + Assertions.assertEquals(recentItems.getItemDatas().size(), 1); + Assertions.assertEquals(recentItems.getCount(), 1); + Assertions.assertEquals(recentItems.getItemDatas().get(0).getId(), item.getId()); + + ItemDataList notag = queryService.findRecentItems("notag", null, null, new Page(), user); + Assertions.assertEquals(notag.getCount(), 0); + } + + @Test + public void should_show_following_if_user_followed_seller() { + User anotherUser = new User("other@email.com", "other", "123", "", ""); + userRepository.save(anotherUser); + + FollowRelation followRelation = new FollowRelation(anotherUser.getId(), user.getId()); + userRepository.saveRelation(followRelation); + + ItemDataList recentItems = + queryService.findRecentItems(null, null, null, new Page(), anotherUser); + Assertions.assertEquals(recentItems.getCount(), 1); + ItemData itemData = recentItems.getItemDatas().get(0); + Assertions.assertTrue(itemData.getProfileData().isFollowing()); + } + + @Test + public void should_get_user_feed() { + User anotherUser = new User("other@email.com", "other", "123", "", ""); + userRepository.save(anotherUser); + + FollowRelation followRelation = new FollowRelation(anotherUser.getId(), user.getId()); + userRepository.saveRelation(followRelation); + + ItemDataList userFeed = queryService.findUserFeed(user, new Page()); + Assertions.assertEquals(userFeed.getCount(), 0); + + ItemDataList anotherUserFeed = queryService.findUserFeed(anotherUser, new Page()); + Assertions.assertEquals(anotherUserFeed.getCount(), 1); + ItemData itemData = anotherUserFeed.getItemDatas().get(0); + Assertions.assertTrue(itemData.getProfileData().isFollowing()); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/application/profile/ProfileQueryServiceTest.java b/.framework/java/backend/src/test/java/io/spring/application/profile/ProfileQueryServiceTest.java new file mode 100644 index 00000000..34ce502d --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/application/profile/ProfileQueryServiceTest.java @@ -0,0 +1,30 @@ +package io.spring.application.profile; + +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ProfileData; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisUserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({ProfileQueryService.class, MyBatisUserRepository.class}) +public class ProfileQueryServiceTest extends DbTestBase { + @Autowired private ProfileQueryService profileQueryService; + @Autowired private UserRepository userRepository; + + @Test + public void should_fetch_profile_success() { + User currentUser = new User("a@test.com", "a", "123", "", ""); + User profileUser = new User("p@test.com", "p", "123", "", ""); + userRepository.save(profileUser); + + Optional optional = + profileQueryService.findByUsername(profileUser.getUsername(), currentUser); + Assertions.assertTrue(optional.isPresent()); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/application/tag/TagsQueryServiceTest.java b/.framework/java/backend/src/test/java/io/spring/application/tag/TagsQueryServiceTest.java new file mode 100644 index 00000000..c74fd30c --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/application/tag/TagsQueryServiceTest.java @@ -0,0 +1,25 @@ +package io.spring.application.tag; + +import io.spring.application.TagsQueryService; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisItemRepository; +import java.util.Arrays; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({TagsQueryService.class, MyBatisItemRepository.class}) +public class TagsQueryServiceTest extends DbTestBase { + @Autowired private TagsQueryService tagsQueryService; + + @Autowired private ItemRepository itemRepository; + + @Test + public void should_get_all_tags() { + itemRepository.save(new Item("test", "test", "test", "image", Arrays.asList("java"), "123")); + Assertions.assertTrue(tagsQueryService.allTags().contains("java")); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/core/item/ItemTest.java b/.framework/java/backend/src/test/java/io/spring/core/item/ItemTest.java new file mode 100644 index 00000000..cf57b424 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/core/item/ItemTest.java @@ -0,0 +1,40 @@ +package io.spring.core.item; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +public class ItemTest { + + @Test + public void should_get_right_slug() { + Item item = new Item("a new title", "desc", "image", Arrays.asList("java"), "123"); + assertThat(item.getSlug(), is("a-new-title")); + } + + @Test + public void should_get_right_slug_with_number_in_title() { + Item item = new Item("a new title 2", "desc", "image", "image", Arrays.asList("java"), "123"); + assertThat(item.getSlug(), is("a-new-title-2")); + } + + @Test + public void should_get_lower_case_slug() { + Item item = new Item("A NEW TITLE", "desc", "image", "image", Arrays.asList("java"), "123"); + assertThat(item.getSlug(), is("a-new-title")); + } + + @Test + public void should_handle_other_language() { + Item item = new Item("δΈ­ζ–‡οΌšζ ‡ι’˜", "desc", "image", "image", Arrays.asList("java"), "123"); + assertThat(item.getSlug(), is("δΈ­ζ–‡-ζ ‡ι’˜")); + } + + @Test + public void should_handle_commas() { + Item item = new Item("what?the.hell,w", "desc", "image", "image", Arrays.asList("java"), "123"); + assertThat(item.getSlug(), is("what-the-hell-w")); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/DbTestBase.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/DbTestBase.java new file mode 100644 index 00000000..80ed81cb --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/DbTestBase.java @@ -0,0 +1,11 @@ +package io.spring.infrastructure; + +import org.mybatis.spring.boot.test.autoconfigure.MybatisTest; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = Replace.NONE) +@MybatisTest +public abstract class DbTestBase {} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/comment/MyBatisCommentRepositoryTest.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/comment/MyBatisCommentRepositoryTest.java new file mode 100644 index 00000000..8cc9a66e --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/comment/MyBatisCommentRepositoryTest.java @@ -0,0 +1,26 @@ +package io.spring.infrastructure.comment; + +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisCommentRepository; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({MyBatisCommentRepository.class}) +public class MyBatisCommentRepositoryTest extends DbTestBase { + @Autowired private CommentRepository commentRepository; + + @Test + public void should_create_and_fetch_comment_success() { + Comment comment = new Comment("content", "123", "456"); + commentRepository.save(comment); + + Optional optional = commentRepository.findById("456", comment.getId()); + Assertions.assertTrue(optional.isPresent()); + Assertions.assertEquals(optional.get(), comment); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/favorite/MyBatisItemFavoriteRepositoryTest.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/favorite/MyBatisItemFavoriteRepositoryTest.java new file mode 100644 index 00000000..cb78cf1c --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/favorite/MyBatisItemFavoriteRepositoryTest.java @@ -0,0 +1,34 @@ +package io.spring.infrastructure.favorite; + +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisItemFavoriteRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({MyBatisItemFavoriteRepository.class}) +public class MyBatisItemFavoriteRepositoryTest extends DbTestBase { + @Autowired private ItemFavoriteRepository itemFavoriteRepository; + + @Autowired + private io.spring.infrastructure.mybatis.mapper.ItemFavoriteMapper itemFavoriteMapper; + + @Test + public void should_save_and_fetch_itemFavorite_success() { + ItemFavorite itemFavorite = new ItemFavorite("123", "456"); + itemFavoriteRepository.save(itemFavorite); + Assertions.assertNotNull( + itemFavoriteMapper.find(itemFavorite.getItemId(), itemFavorite.getUserId())); + } + + @Test + public void should_remove_favorite_success() { + ItemFavorite itemFavorite = new ItemFavorite("123", "456"); + itemFavoriteRepository.save(itemFavorite); + itemFavoriteRepository.remove(itemFavorite); + Assertions.assertFalse(itemFavoriteRepository.find("123", "456").isPresent()); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/item/ItemRepositoryTransactionTest.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/item/ItemRepositoryTransactionTest.java new file mode 100644 index 00000000..2689d03c --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/item/ItemRepositoryTransactionTest.java @@ -0,0 +1,41 @@ +package io.spring.infrastructure.item; + +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.mybatis.mapper.ItemMapper; +import java.util.Arrays; +import org.junit.jupiter.api.Assertions; +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.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class ItemRepositoryTransactionTest { + @Autowired private ItemRepository itemRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private ItemMapper itemMapper; + + @Test + public void transactional_test() { + User user = new User("aisensiy@gmail.com", "aisensiy", "123", "bio", "default"); + userRepository.save(user); + Item item = + new Item("test", "desc", "image", "image", Arrays.asList("java", "spring"), user.getId()); + itemRepository.save(item); + Item anotherItem = + new Item("test", "desc", "image", "image", Arrays.asList("java", "spring", "other"), user.getId()); + try { + itemRepository.save(anotherItem); + } catch (Exception e) { + Assertions.assertNull(itemMapper.findTag("other")); + } + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/item/MyBatisItemRepositoryTest.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/item/MyBatisItemRepositoryTest.java new file mode 100644 index 00000000..ee415a7a --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/item/MyBatisItemRepositoryTest.java @@ -0,0 +1,66 @@ +package io.spring.infrastructure.item; + +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.item.Tag; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisItemRepository; +import io.spring.infrastructure.repository.MyBatisUserRepository; +import java.util.Arrays; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import({MyBatisItemRepository.class, MyBatisUserRepository.class}) +public class MyBatisItemRepositoryTest extends DbTestBase { + @Autowired private ItemRepository itemRepository; + + @Autowired private UserRepository userRepository; + + private Item item; + + @BeforeEach + public void setUp() { + User user = new User("aisensiy@gmail.com", "aisensiy", "123", "bio", "default"); + userRepository.save(user); + item = new Item("test", "desc", "image", "image", Arrays.asList("java", "spring"), user.getId()); + } + + @Test + public void should_create_and_fetch_item_success() { + itemRepository.save(item); + Optional optional = itemRepository.findById(item.getId()); + Assertions.assertTrue(optional.isPresent()); + Assertions.assertEquals(optional.get(), item); + Assertions.assertTrue(optional.get().getTags().contains(new Tag("java"))); + Assertions.assertTrue(optional.get().getTags().contains(new Tag("spring"))); + } + + @Test + public void should_update_and_fetch_item_success() { + itemRepository.save(item); + + String newTitle = "new test 2"; + item.update(newTitle, "", ""); + itemRepository.save(item); + System.out.println(item.getSlug()); + Optional optional = itemRepository.findBySlug(item.getSlug()); + Assertions.assertTrue(optional.isPresent()); + Item fetched = optional.get(); + Assertions.assertEquals(fetched.getTitle(), newTitle); + Assertions.assertNotEquals(fetched.getBody(), ""); + } + + @Test + public void should_delete_item() { + itemRepository.save(item); + + itemRepository.remove(item); + Assertions.assertFalse(itemRepository.findById(item.getId()).isPresent()); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java new file mode 100644 index 00000000..12929118 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java @@ -0,0 +1,41 @@ +package io.spring.infrastructure.service; + +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class DefaultJwtServiceTest { + + private JwtService jwtService; + + @BeforeEach + public void setUp() { + jwtService = new DefaultJwtService("123123123123123123123123123123123123123123123123123123123123", 3600); + } + + @Test + public void should_generate_and_parse_token() { + User user = new User("email@email.com", "username", "123", "", ""); + String token = jwtService.toToken(user); + Assertions.assertNotNull(token); + Optional optional = jwtService.getSubFromToken(token); + Assertions.assertTrue(optional.isPresent()); + Assertions.assertEquals(optional.get(), user.getId()); + } + + @Test + public void should_get_null_with_wrong_jwt() { + Optional optional = jwtService.getSubFromToken("123"); + Assertions.assertFalse(optional.isPresent()); + } + + @Test + public void should_get_null_with_expired_jwt() { + String token = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhaXNlbnNpeSIsImV4cCI6MTUwMjE2MTIwNH0.SJB-U60WzxLYNomqLo4G3v3LzFxJKuVrIud8D8Lz3-mgpo9pN1i7C8ikU_jQPJGm8HsC1CquGMI-rSuM7j6LDA"; + Assertions.assertFalse(jwtService.getSubFromToken(token).isPresent()); + } +} diff --git a/.framework/java/backend/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java b/.framework/java/backend/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java new file mode 100644 index 00000000..39876111 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java @@ -0,0 +1,73 @@ +package io.spring.infrastructure.user; + +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisUserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import(MyBatisUserRepository.class) +public class MyBatisUserRepositoryTest extends DbTestBase { + @Autowired private UserRepository userRepository; + private User user; + + @BeforeEach + public void setUp() { + user = new User("aisensiy@163.com", "aisensiy", "123", "", "default"); + } + + @Test + public void should_save_and_fetch_user_success() { + userRepository.save(user); + Optional userOptional = userRepository.findByUsername("aisensiy"); + Assertions.assertEquals(userOptional.get(), user); + Optional userOptional2 = userRepository.findByEmail("aisensiy@163.com"); + Assertions.assertEquals(userOptional2.get(), user); + } + + @Test + public void should_update_user_success() { + String newEmail = "newemail@email.com"; + user.update(newEmail, "", "", "", ""); + userRepository.save(user); + Optional optional = userRepository.findByUsername(user.getUsername()); + Assertions.assertTrue(optional.isPresent()); + Assertions.assertEquals(optional.get().getEmail(), newEmail); + + String newUsername = "newUsername"; + user.update("", newUsername, "", "", ""); + userRepository.save(user); + optional = userRepository.findByEmail(user.getEmail()); + Assertions.assertTrue(optional.isPresent()); + Assertions.assertEquals(optional.get().getUsername(), newUsername); + Assertions.assertEquals(optional.get().getImage(), user.getImage()); + } + + @Test + public void should_create_new_user_follow_success() { + User other = new User("other@example.com", "other", "123", "", ""); + userRepository.save(other); + + FollowRelation followRelation = new FollowRelation(user.getId(), other.getId()); + userRepository.saveRelation(followRelation); + Assertions.assertTrue(userRepository.findRelation(user.getId(), other.getId()).isPresent()); + } + + @Test + public void should_unfollow_user_success() { + User other = new User("other@example.com", "other", "123", "", ""); + userRepository.save(other); + + FollowRelation followRelation = new FollowRelation(user.getId(), other.getId()); + userRepository.saveRelation(followRelation); + + userRepository.removeRelation(followRelation); + Assertions.assertFalse(userRepository.findRelation(user.getId(), other.getId()).isPresent()); + } +} diff --git a/.framework/java/charts/.helmignore b/.framework/java/charts/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/.framework/java/charts/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/.framework/java/charts/Chart.yaml b/.framework/java/charts/Chart.yaml new file mode 100644 index 00000000..b2beb191 --- /dev/null +++ b/.framework/java/charts/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: app +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/.framework/java/charts/templates/_helpers.yml b/.framework/java/charts/templates/_helpers.yml new file mode 100644 index 00000000..49515f24 --- /dev/null +++ b/.framework/java/charts/templates/_helpers.yml @@ -0,0 +1,24 @@ +{{- define "anythink-tenant.backendHost" -}} + https://{{- .Release.Namespace }}-api. + {{- if eq .Values.clusterEnv "staging" }} + {{- .Values.stagingBackendHost }} + {{- else }} + {{- .Values.productionBackendHost }} + {{- end }} +{{- end }} + +{{- define "anythink-tenant.backendRepository" -}} + {{- if eq .Values.clusterEnv "staging" }} + {{- .Values.backend.image.stagingRepository }} + {{- else }} + {{- .Values.backend.image.repository }} + {{- end }} +{{- end }} + +{{- define "anythink-tenant.frontendRepository" -}} + {{- if eq .Values.clusterEnv "staging" }} + {{- .Values.frontend.image.stagingRepository }} + {{- else }} + {{- .Values.frontend.image.repository }} + {{- end }} +{{- end }} diff --git a/.framework/java/charts/templates/anythink-backend-deployment.yaml b/.framework/java/charts/templates/anythink-backend-deployment.yaml new file mode 100644 index 00000000..7b209f50 --- /dev/null +++ b/.framework/java/charts/templates/anythink-backend-deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: {{ .Values.backend.serviceName }} + name: {{ .Values.backend.serviceName }} +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + app: {{ .Values.backend.serviceName }} + strategy: + type: Recreate + template: + metadata: + labels: + app: {{ .Values.backend.serviceName }} + date: {{ now | unixEpoch | quote }} + spec: + containers: + - args: + - sh + - -c + - "gradlew bootRun" + env: + - name: APP_ENV + value: dev + - name: SECRET_KEY + value: secret + - name: DEBUG + value: "True" + - name: DATABASE_URL + value: "{{ .Values.database.connectionProtocol }}{{ .Values.database.env.password }}:@{{ .Values.database.serviceName }}:{{ .Values.database.servicePort }}/{{ .Values.database.databaseName }}" + image: "{{ include "anythink-tenant.backendRepository" .}}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + name: {{ .Values.backend.serviceName }} + ports: + - containerPort: {{ .Values.backend.containerPort }} + name: http + protocol: TCP + startupProbe: + httpGet: + path: /health + port: http + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + readinessProbe: + httpGet: + path: /health + port: http + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + initContainers: + env: + - name: APP_ENV + value: dev + - name: SECRET_KEY + value: secret + - name: DEBUG + value: "True" + - name: DATABASE_URL + value: "{{ .Values.database.connectionProtocol}}{{ .Values.database.env.password}}:@{{ .Values.database.serviceName }}:{{ .Values.database.servicePort }}/{{ .Values.database.databaseName }}" + image: "{{ include "anythink-tenant.backendRepository" .}}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + name: db-migrations + restartPolicy: Always diff --git a/.framework/java/charts/templates/anythink-backend-service.yaml b/.framework/java/charts/templates/anythink-backend-service.yaml new file mode 100644 index 00000000..21bb5161 --- /dev/null +++ b/.framework/java/charts/templates/anythink-backend-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + app: {{ .Values.backend.serviceName }} + name: {{ .Values.backend.serviceName }} +spec: + ports: + - name: "{{ .Values.backend.containerPort }}" + port: {{ .Values.backend.containerPort }} + targetPort: {{ .Values.backend.containerPort }} + selector: + app: {{ .Values.backend.serviceName }} diff --git a/.framework/java/charts/templates/anythink-frontend-deployment.yaml b/.framework/java/charts/templates/anythink-frontend-deployment.yaml new file mode 100644 index 00000000..f9be249d --- /dev/null +++ b/.framework/java/charts/templates/anythink-frontend-deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: {{ .Values.frontend.serviceName }} + name: {{ .Values.frontend.serviceName }} +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + app: {{ .Values.frontend.serviceName }} + strategy: + type: Recreate + template: + metadata: + labels: + app: {{ .Values.frontend.serviceName }} + date: {{ now | unixEpoch | quote }} + spec: + containers: + - args: + - sh + - -c + - yarn start + env: + - name: NODE_ENV + value: development + - name: PORT + value: "{{ .Values.frontend.containerPort }}" + - name: REACT_APP_BACKEND_URL + value: {{ include "anythink-tenant.backendHost" .}} + image: "{{ include "anythink-tenant.frontendRepository" .}}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + name: {{ .Values.frontend.serviceName }} + ports: + - containerPort: {{ .Values.frontend.containerPort }} + name: http + protocol: TCP + startupProbe: + httpGet: + path: / + port: http + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + restartPolicy: Always diff --git a/.framework/java/charts/templates/anythink-frontend-service.yaml b/.framework/java/charts/templates/anythink-frontend-service.yaml new file mode 100644 index 00000000..217f8c56 --- /dev/null +++ b/.framework/java/charts/templates/anythink-frontend-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ .Values.frontend.serviceName }} + name: {{ .Values.frontend.serviceName }} +spec: + ports: + - name: "{{ .Values.frontend.containerPort }}" + port: {{ .Values.frontend.containerPort }} + targetPort: {{ .Values.frontend.containerPort }} + selector: + app: {{ .Values.frontend.serviceName }} diff --git a/.framework/java/charts/templates/database-deployment.yaml b/.framework/java/charts/templates/database-deployment.yaml new file mode 100644 index 00000000..19752ee2 --- /dev/null +++ b/.framework/java/charts/templates/database-deployment.yaml @@ -0,0 +1,44 @@ +{{- if .Values.database.deploy }} +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: {{ .Values.database.serviceName }} + name: {{ .Values.database.serviceName }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ .Values.database.serviceName }} + strategy: + type: Recreate + template: + metadata: + labels: + app: {{ .Values.database.serviceName }} + spec: + containers: + - image: "{{ .Values.database.image.repository }}:{{ .Values.database.image.tag }}" + name: {{ .Values.database.serviceName }} + env: + - name: POSTGRES_HOST_AUTH_METHOD + value: trust + - name: POSTGRES_USER + value: {{ .Values.database.env.userName }} + - name: POSTGRES_PASSWORD + value: {{ .Values.database.env.password }} + - name: POSTGRES_DB + value: {{ .Values.database.databaseName }} + imagePullPolicy: {{ .Values.database.image.pullPolicy }} + ports: + - containerPort: {{ .Values.database.containerPort }} + resources: {} + volumeMounts: + - mountPath: /data/db + name: {{ .Values.database.serviceName }}-0 + restartPolicy: Always + volumes: + - name: {{ .Values.database.serviceName }}-0 + persistentVolumeClaim: + claimName: {{ .Values.database.serviceName }}-0 +{{- end }} diff --git a/.framework/java/charts/templates/database-pvc.yaml b/.framework/java/charts/templates/database-pvc.yaml new file mode 100644 index 00000000..88517f33 --- /dev/null +++ b/.framework/java/charts/templates/database-pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + labels: + app: {{ .Values.database.serviceName }}-0 + name: {{ .Values.database.serviceName }}-0 +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Mi diff --git a/.framework/java/charts/templates/database-service.yaml b/.framework/java/charts/templates/database-service.yaml new file mode 100644 index 00000000..80b47d31 --- /dev/null +++ b/.framework/java/charts/templates/database-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ .Values.database.serviceName }} + name: {{ .Values.database.serviceName }} +spec: + ports: + - name: "{{ .Values.database.servicePort }}" + port: {{ .Values.database.servicePort }} + targetPort: {{ .Values.database.servicePort }} + selector: + app: {{ .Values.database.serviceName }} diff --git a/.framework/java/charts/values.yaml b/.framework/java/charts/values.yaml new file mode 100644 index 00000000..0464a04e --- /dev/null +++ b/.framework/java/charts/values.yaml @@ -0,0 +1,70 @@ +clusterEnv: "" +productionBackendHost: "prod.anythink.market" +stagingBackendHost: "staging.anythink.market" + +backend: + serviceName: anythink-backend + containerPort: 3000 + replicaCount: 1 + service: + type: ClusterIP + port: 80 + image: + repository: 498915426792.dkr.ecr.us-east-2.amazonaws.com/anythink-backend + stagingRepository: 498915426792.dkr.ecr.us-east-2.amazonaws.com/staging-anythink-backend + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + resources: + limits: + cpu: 100m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +frontend: + serviceName: anythink-frontend + containerPort: 3001 + replicaCount: 1 + service: + type: ClusterIP + port: 80 + image: + repository: 498915426792.dkr.ecr.us-east-2.amazonaws.com/anythink-frontend + stagingRepository: 498915426792.dkr.ecr.us-east-2.amazonaws.com/staging-anythink-frontend + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + resources: + limits: + cpu: 600m + memory: 768Mi + requests: + cpu: 100m + memory: 128Mi + +database: + deploy: true + connectionProtocol: postgresql:// + serviceName: postgres-python + containerPort: 5433 + servicePort: 5432 + databaseName: anythink-market + replicaCount: 1 + env: + password: postgres + service: + type: ClusterIP + port: 80 + image: + repository: postgres + pullPolicy: IfNotPresent + tag: "latest" + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi diff --git a/.framework/java/docker-compose.yml b/.framework/java/docker-compose.yml new file mode 100644 index 00000000..e0b6bbbd --- /dev/null +++ b/.framework/java/docker-compose.yml @@ -0,0 +1,61 @@ +version: "3.8" +services: + anythink-backend-java: + build: ./backend + container_name: anythink-backend-java + command: sh -c "cd backend && /wait-for-it.sh postgres-java:5432 -q -t 60 && ./gradlew bootRun" + + environment: + - PORT=3000 + - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN} + working_dir: /usr/src + volumes: + - ./:/usr/src/ + - /usr/src/backend/node_modules + ports: + - "3000:3000" + depends_on: + - "postgres-java" + + anythink-frontend-react: + build: ./frontend + container_name: anythink-frontend-react + command: sh -c "cd frontend && yarn install && /wait-for-it.sh anythink-backend-java:3000 -t 120 --strict -- curl --head -X GET --retry 30 --retry-connrefused --retry-delay 1 anythink-backend-java:3000/api/ping && yarn start" + environment: + - NODE_ENV=development + - PORT=3001 + - REACT_APP_BACKEND_URL=${CODESPACE_BACKEND_URL:-http://localhost:3000} + - WDS_SOCKET_PORT=${CODESPACE_WDS_SOCKET_PORT:-3001} + working_dir: /usr/src + volumes: + - ./:/usr/src/ + - /usr/src/frontend/node_modules + ports: + - "3001:3001" + depends_on: + - "anythink-backend-java" + + postgres-java: + container_name: postgres-java + restart: on-failure + image: postgres + logging: + driver: none + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_PASSWORD: postgres + POSTGRES_DB: anythink-market + volumes: + - ~/postgres/data:/data/db + ports: + - '5433:5432' + + anythink-ack: + build: ./frontend + container_name: anythink-ack + command: sh -c "/wait-for-it.sh anythink-frontend-react:3001 -q -t 1000 && ./anythink_ack.sh" + working_dir: /usr/src + volumes: + - ./:/usr/src/ + depends_on: + - "anythink-frontend-react"