From b888d59049dd738be26b1397e5c387093b175443 Mon Sep 17 00:00:00 2001 From: Dar Malovani Date: Sun, 7 Jan 2024 16:49:22 +0200 Subject: [PATCH 1/5] feat: add java repo --- .framework/java/backend/.gitignore | 26 ++ .framework/java/backend/Dockerfile | 15 + .framework/java/backend/LICENSE | 21 + .framework/java/backend/README.md | 37 ++ .framework/java/backend/build.gradle | 73 ++++ .../backend/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + .framework/java/backend/gradlew | 185 +++++++++ .framework/java/backend/gradlew.bat | 89 ++++ .../java/io/spring/JacksonCustomizations.java | 44 ++ .../main/java/io/spring/MyBatisConfig.java | 8 + .../java/io/spring/RealWorldApplication.java | 12 + .../backend/src/main/java/io/spring/Util.java | 7 + .../main/java/io/spring/api/CommentsApi.java | 102 +++++ .../java/io/spring/api/CurrentUserApi.java | 58 +++ .../src/main/java/io/spring/api/ItemApi.java | 88 ++++ .../java/io/spring/api/ItemFavoriteApi.java | 62 +++ .../src/main/java/io/spring/api/ItemsApi.java | 60 +++ .../main/java/io/spring/api/ProfileApi.java | 78 ++++ .../src/main/java/io/spring/api/TagsApi.java | 26 ++ .../src/main/java/io/spring/api/UsersApi.java | 79 ++++ .../exception/CustomizeExceptionHandler.java | 109 +++++ .../spring/api/exception/ErrorResource.java | 18 + .../exception/ErrorResourceSerializer.java | 42 ++ .../api/exception/FieldErrorResource.java | 15 + .../InvalidAuthenticationException.java | 8 + .../exception/InvalidRequestException.java | 17 + .../exception/NoAuthorizationException.java | 7 + .../exception/ResourceNotFoundException.java | 7 + .../spring/api/security/JwtTokenFilter.java | 62 +++ .../api/security/WebSecurityConfig.java | 83 ++++ .../application/CommentQueryService.java | 85 ++++ .../application/CursorPageParameter.java | 40 ++ .../io/spring/application/CursorPager.java | 44 ++ .../io/spring/application/DateTimeCursor.java | 23 ++ .../spring/application/ItemQueryService.java | 184 +++++++++ .../main/java/io/spring/application/Node.java | 5 + .../main/java/io/spring/application/Page.java | 31 ++ .../io/spring/application/PageCursor.java | 18 + .../application/ProfileQueryService.java | 35 ++ .../spring/application/TagsQueryService.java | 16 + .../spring/application/UserQueryService.java | 17 + .../spring/application/data/CommentData.java | 29 ++ .../io/spring/application/data/ItemData.java | 33 ++ .../spring/application/data/ItemDataList.java | 20 + .../application/data/ItemFavoriteCount.java | 9 + .../spring/application/data/ProfileData.java | 17 + .../io/spring/application/data/UserData.java | 16 + .../application/data/UserWithToken.java | 20 + .../item/DuplicatedItemConstraint.java | 21 + .../item/DuplicatedItemValidator.java | 18 + .../application/item/ItemCommandService.java | 38 ++ .../spring/application/item/NewItemParam.java | 28 ++ .../application/item/UpdateItemParam.java | 16 + .../user/DuplicatedEmailConstraint.java | 16 + .../user/DuplicatedEmailValidator.java | 17 + .../user/DuplicatedUsernameConstraint.java | 16 + .../user/DuplicatedUsernameValidator.java | 17 + .../application/user/RegisterParam.java | 26 ++ .../application/user/UpdateUserCommand.java | 14 + .../application/user/UpdateUserParam.java | 25 ++ .../spring/application/user/UserService.java | 106 +++++ .../java/io/spring/core/comment/Comment.java | 26 ++ .../core/comment/CommentRepository.java | 11 + .../io/spring/core/favorite/ItemFavorite.java | 18 + .../core/favorite/ItemFavoriteRepository.java | 11 + .../main/java/io/spring/core/item/Item.java | 70 ++++ .../io/spring/core/item/ItemRepository.java | 14 + .../main/java/io/spring/core/item/Tag.java | 19 + .../core/service/AuthorizationService.java | 15 + .../io/spring/core/service/JwtService.java | 12 + .../io/spring/core/user/FollowRelation.java | 17 + .../main/java/io/spring/core/user/User.java | 50 +++ .../io/spring/core/user/UserRepository.java | 21 + .../io/spring/graphql/CommentDatafetcher.java | 122 ++++++ .../io/spring/graphql/CommentMutation.java | 68 ++++ .../io/spring/graphql/ItemDatafetcher.java | 384 ++++++++++++++++++ .../java/io/spring/graphql/ItemMutation.java | 115 ++++++ .../java/io/spring/graphql/MeDatafetcher.java | 61 +++ .../io/spring/graphql/ProfileDatafetcher.java | 71 ++++ .../io/spring/graphql/RelationMutation.java | 65 +++ .../java/io/spring/graphql/SecurityUtil.java | 19 + .../io/spring/graphql/TagDatafetcher.java | 19 + .../java/io/spring/graphql/UserMutation.java | 93 +++++ .../exception/AuthenticationException.java | 3 + .../GraphQLCustomizeExceptionHandler.java | 114 ++++++ .../mybatis/DateTimeHandler.java | 44 ++ .../mybatis/mapper/CommentMapper.java | 14 + .../mybatis/mapper/ItemFavoriteMapper.java | 14 + .../mybatis/mapper/ItemMapper.java | 25 ++ .../mybatis/mapper/UserMapper.java | 25 ++ .../readservice/CommentReadService.java | 18 + .../readservice/ItemFavoritesReadService.java | 19 + .../mybatis/readservice/ItemReadService.java | 42 ++ .../mybatis/readservice/TagReadService.java | 9 + .../mybatis/readservice/UserReadService.java | 13 + .../UserRelationshipQueryService.java | 16 + .../repository/MyBatisCommentRepository.java | 33 ++ .../MyBatisItemFavoriteRepository.java | 35 ++ .../repository/MyBatisItemRepository.java | 57 +++ .../repository/MyBatisUserRepository.java | 60 +++ .../service/DefaultJwtService.java | 54 +++ .../resources/application-test.properties | 1 + .../src/main/resources/application.properties | 30 ++ .../db/migration/V1__create_tables.sql | 49 +++ .../main/resources/mapper/CommentMapper.xml | 35 ++ .../resources/mapper/CommentReadService.xml | 42 ++ .../resources/mapper/ItemFavoriteMapper.xml | 21 + .../mapper/ItemFavoritesReadService.xml | 30 ++ .../src/main/resources/mapper/ItemMapper.xml | 82 ++++ .../main/resources/mapper/ItemReadService.xml | 162 ++++++++ .../main/resources/mapper/TagReadService.xml | 7 + .../main/resources/mapper/TransferData.xml | 40 ++ .../src/main/resources/mapper/UserMapper.xml | 61 +++ .../main/resources/mapper/UserReadService.xml | 10 + .../mapper/UserRelationshipQueryService.xml | 18 + .../src/main/resources/schema/schema.graphqls | 177 ++++++++ .../io/spring/RealworldApplicationTests.java | 11 + .../src/test/java/io/spring/TestHelper.java | 42 ++ .../java/io/spring/api/CommentsApiTest.java | 165 ++++++++ .../io/spring/api/CurrentUserApiTest.java | 179 ++++++++ .../test/java/io/spring/api/ItemApiTest.java | 226 +++++++++++ .../io/spring/api/ItemFavoriteApiTest.java | 103 +++++ .../test/java/io/spring/api/ItemsApiTest.java | 173 ++++++++ .../java/io/spring/api/ListItemApiTest.java | 75 ++++ .../java/io/spring/api/ProfileApiTest.java | 96 +++++ .../io/spring/api/TestWithCurrentUser.java | 49 +++ .../test/java/io/spring/api/UsersApiTest.java | 271 ++++++++++++ .../comment/CommentQueryServiceTest.java | 76 ++++ .../item/ItemQueryServiceTest.java | 230 +++++++++++ .../profile/ProfileQueryServiceTest.java | 30 ++ .../application/tag/TagsQueryServiceTest.java | 25 ++ .../java/io/spring/core/item/ItemTest.java | 40 ++ .../io/spring/infrastructure/DbTestBase.java | 11 + .../comment/MyBatisCommentRepositoryTest.java | 26 ++ .../MyBatisItemFavoriteRepositoryTest.java | 34 ++ .../item/ItemRepositoryTransactionTest.java | 41 ++ .../item/MyBatisItemRepositoryTest.java | 66 +++ .../service/DefaultJwtServiceTest.java | 41 ++ .../user/MyBatisUserRepositoryTest.java | 73 ++++ .framework/java/charts/.helmignore | 23 ++ .framework/java/charts/Chart.yaml | 24 ++ .framework/java/charts/templates/_helpers.yml | 24 ++ .../anythink-backend-deployment.yaml | 70 ++++ .../templates/anythink-backend-service.yaml | 14 + .../anythink-frontend-deployment.yaml | 55 +++ .../templates/anythink-frontend-service.yaml | 13 + .../charts/templates/database-deployment.yaml | 44 ++ .../java/charts/templates/database-pvc.yaml | 12 + .../charts/templates/database-service.yaml | 13 + .framework/java/charts/values.yaml | 70 ++++ .framework/java/docker-compose.yml | 61 +++ 152 files changed, 7610 insertions(+) create mode 100644 .framework/java/backend/.gitignore create mode 100644 .framework/java/backend/Dockerfile create mode 100644 .framework/java/backend/LICENSE create mode 100644 .framework/java/backend/README.md create mode 100644 .framework/java/backend/build.gradle create mode 100644 .framework/java/backend/gradle/wrapper/gradle-wrapper.jar create mode 100644 .framework/java/backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 .framework/java/backend/gradlew create mode 100644 .framework/java/backend/gradlew.bat create mode 100644 .framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java create mode 100644 .framework/java/backend/src/main/java/io/spring/MyBatisConfig.java create mode 100644 .framework/java/backend/src/main/java/io/spring/RealWorldApplication.java create mode 100644 .framework/java/backend/src/main/java/io/spring/Util.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/CommentsApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/CurrentUserApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/ItemApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/ItemFavoriteApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/ItemsApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/ProfileApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/TagsApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/UsersApi.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/CustomizeExceptionHandler.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/ErrorResource.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/ErrorResourceSerializer.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/FieldErrorResource.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/InvalidAuthenticationException.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/InvalidRequestException.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/NoAuthorizationException.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/exception/ResourceNotFoundException.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/security/JwtTokenFilter.java create mode 100644 .framework/java/backend/src/main/java/io/spring/api/security/WebSecurityConfig.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/CommentQueryService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/CursorPageParameter.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/CursorPager.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/DateTimeCursor.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/ItemQueryService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/Node.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/Page.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/PageCursor.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/ProfileQueryService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/TagsQueryService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/UserQueryService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/CommentData.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/ItemData.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/ItemDataList.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/ItemFavoriteCount.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/ProfileData.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/UserData.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/data/UserWithToken.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemConstraint.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemValidator.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/item/ItemCommandService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/item/NewItemParam.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/item/UpdateItemParam.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailConstraint.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/DuplicatedEmailValidator.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameConstraint.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/DuplicatedUsernameValidator.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/RegisterParam.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/UpdateUserCommand.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/UpdateUserParam.java create mode 100644 .framework/java/backend/src/main/java/io/spring/application/user/UserService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/comment/Comment.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/comment/CommentRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavorite.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/favorite/ItemFavoriteRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/item/Item.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/item/ItemRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/item/Tag.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/service/AuthorizationService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/service/JwtService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/user/FollowRelation.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/user/User.java create mode 100644 .framework/java/backend/src/main/java/io/spring/core/user/UserRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/CommentDatafetcher.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/CommentMutation.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/ItemDatafetcher.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/ItemMutation.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/MeDatafetcher.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/ProfileDatafetcher.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/RelationMutation.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/SecurityUtil.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/TagDatafetcher.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/UserMutation.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/exception/AuthenticationException.java create mode 100644 .framework/java/backend/src/main/java/io/spring/graphql/exception/GraphQLCustomizeExceptionHandler.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/DateTimeHandler.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemFavoriteMapper.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemMapper.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/CommentReadService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemFavoritesReadService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/ItemReadService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/TagReadService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserReadService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/readservice/UserRelationshipQueryService.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisCommentRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemFavoriteRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java create mode 100644 .framework/java/backend/src/main/java/io/spring/infrastructure/service/DefaultJwtService.java create mode 100644 .framework/java/backend/src/main/resources/application-test.properties create mode 100644 .framework/java/backend/src/main/resources/application.properties create mode 100644 .framework/java/backend/src/main/resources/db/migration/V1__create_tables.sql create mode 100644 .framework/java/backend/src/main/resources/mapper/CommentMapper.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/CommentReadService.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/ItemFavoriteMapper.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/ItemFavoritesReadService.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/ItemMapper.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/ItemReadService.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/TagReadService.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/TransferData.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/UserMapper.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/UserReadService.xml create mode 100644 .framework/java/backend/src/main/resources/mapper/UserRelationshipQueryService.xml create mode 100644 .framework/java/backend/src/main/resources/schema/schema.graphqls create mode 100644 .framework/java/backend/src/test/java/io/spring/RealworldApplicationTests.java create mode 100644 .framework/java/backend/src/test/java/io/spring/TestHelper.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/CommentsApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/CurrentUserApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/ItemApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/ItemFavoriteApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/ItemsApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/ListItemApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/ProfileApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/TestWithCurrentUser.java create mode 100644 .framework/java/backend/src/test/java/io/spring/api/UsersApiTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/application/comment/CommentQueryServiceTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/application/item/ItemQueryServiceTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/application/profile/ProfileQueryServiceTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/application/tag/TagsQueryServiceTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/core/item/ItemTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/DbTestBase.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/comment/MyBatisCommentRepositoryTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/favorite/MyBatisItemFavoriteRepositoryTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/item/ItemRepositoryTransactionTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/item/MyBatisItemRepositoryTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java create mode 100644 .framework/java/backend/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java create mode 100644 .framework/java/charts/.helmignore create mode 100644 .framework/java/charts/Chart.yaml create mode 100644 .framework/java/charts/templates/_helpers.yml create mode 100644 .framework/java/charts/templates/anythink-backend-deployment.yaml create mode 100644 .framework/java/charts/templates/anythink-backend-service.yaml create mode 100644 .framework/java/charts/templates/anythink-frontend-deployment.yaml create mode 100644 .framework/java/charts/templates/anythink-frontend-service.yaml create mode 100644 .framework/java/charts/templates/database-deployment.yaml create mode 100644 .framework/java/charts/templates/database-pvc.yaml create mode 100644 .framework/java/charts/templates/database-service.yaml create mode 100644 .framework/java/charts/values.yaml create mode 100644 .framework/java/docker-compose.yml diff --git a/.framework/java/backend/.gitignore b/.framework/java/backend/.gitignore new file mode 100644 index 000000000..dfcbb1287 --- /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 000000000..a61f3ee60 --- /dev/null +++ b/.framework/java/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM openjdk:11 + +RUN curl https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh --output /wait-for-it.sh + +RUN chmod +x /wait-for-it.sh + +#COPY /backend /usr/src/backend + +WORKDIR /usr/src +#COPY backend ./backend +#COPY .wilco ./.wilco + +# Pre-install npm packages +WORKDIR /usr/src/backend + diff --git a/.framework/java/backend/LICENSE b/.framework/java/backend/LICENSE new file mode 100644 index 000000000..a6fd2b775 --- /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 000000000..64cb7fc74 --- /dev/null +++ b/.framework/java/backend/README.md @@ -0,0 +1,37 @@ +# Anythink Market Backend + +> ### Spring boot + MyBatis codebase containing real world examples (CRUD, auth, advanced patterns, etc) + +# 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/tags . +Alternatively, you can run + + curl http://localhost:3000/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 000000000..decaba7a7 --- /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 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 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 000000000..41dfb8790 --- /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 000000000..4f906e0c8 --- /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 000000000..ac1b06f93 --- /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/JacksonCustomizations.java b/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java new file mode 100644 index 000000000..86fab0abe --- /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 realWorldModules() { + return new RealWorldModules(); + } + + public static class RealWorldModules extends SimpleModule { + public RealWorldModules() { + 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 000000000..d1f741cfb --- /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/RealWorldApplication.java b/.framework/java/backend/src/main/java/io/spring/RealWorldApplication.java new file mode 100644 index 000000000..c9f34e1a5 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/RealWorldApplication.java @@ -0,0 +1,12 @@ +package io.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RealWorldApplication { + + public static void main(String[] args) { + SpringApplication.run(RealWorldApplication.class, args); + } +} 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 000000000..d2512acca --- /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 000000000..c8215bc30 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java @@ -0,0 +1,102 @@ +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 -> { + if (!AuthorizationService.canWriteComment(user, item, comment)) { + throw new NoAuthorizationException(); + } + 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 000000000..e096aec0b --- /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 000000000..69035e794 --- /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 000000000..75eff6ad2 --- /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 000000000..210c21133 --- /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 000000000..1cf7bca3b --- /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 000000000..e3991832a --- /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 000000000..d91321e82 --- /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 000000000..ade3ff4ff --- /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 000000000..1c2780580 --- /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 000000000..2ce3816a6 --- /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 000000000..13d57314d --- /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 000000000..96af7a838 --- /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 000000000..68b6c868a --- /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 000000000..67414233e --- /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 000000000..8401e5267 --- /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 000000000..1b5c50146 --- /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 000000000..bdffa9412 --- /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 000000000..dfba6efff --- /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 000000000..195313736 --- /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 000000000..13d55d4cd --- /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 000000000..cfcc86bc8 --- /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 000000000..3ac17f704 --- /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 000000000..e4ccac8a6 --- /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 000000000..d273e994f --- /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 000000000..0279f3b20 --- /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 000000000..d92542d5b --- /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 000000000..12e0790cb --- /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 000000000..f0f901ae2 --- /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 000000000..a31dbf434 --- /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 000000000..e65f04cb7 --- /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 000000000..983d17c91 --- /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 000000000..6d875df01 --- /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 000000000..82ef5f959 --- /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 000000000..c50cc190a --- /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 000000000..eac7f1b6b --- /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 000000000..d0d0aaa51 --- /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 000000000..658acf064 --- /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 000000000..528d6d6d1 --- /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 000000000..9fb1848f9 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/NewItemParam.java @@ -0,0 +1,28 @@ +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; + + @NotBlank(message = "can't be empty") + 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 000000000..d2f214ef6 --- /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 000000000..e41eb009e --- /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 000000000..e30711465 --- /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 000000000..4f365b789 --- /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 000000000..ae1fd21aa --- /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 000000000..3ba1234d3 --- /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 000000000..9df523010 --- /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 000000000..54cd77471 --- /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 000000000..48c6735b8 --- /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 000000000..a7a6bc4b5 --- /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 000000000..ad3ab070b --- /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 000000000..39ce24d68 --- /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 000000000..61db705a6 --- /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 000000000..c13a724d3 --- /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 000000000..6dea2ea98 --- /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 000000000..1433450dc --- /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 000000000..94b949ba0 --- /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 000000000..d1430768b --- /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 000000000..7d7b53870 --- /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 000000000..3044d5034 --- /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 000000000..f52c7725d --- /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 000000000..1102ba315 --- /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 000000000..7bcbf995d --- /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 000000000..faffa4ee0 --- /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 000000000..de4c4ba3a --- /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 000000000..939859677 --- /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 000000000..0afc3f480 --- /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 000000000..317b4fcc2 --- /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 000000000..24b723b23 --- /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 000000000..6b70bf5f8 --- /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 000000000..581a5b7b5 --- /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 000000000..417029f72 --- /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 000000000..bf4768b3b --- /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 000000000..19323e565 --- /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 000000000..5b7c3cc1d --- /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 000000000..4ff92dcd2 --- /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 000000000..4d114865d --- /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 000000000..54f36c76a --- /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 000000000..2c27b7c7a --- /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 000000000..c913a230f --- /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 000000000..e8198f9f2 --- /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 000000000..873768746 --- /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 000000000..ae25a48de --- /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 000000000..4a8b23041 --- /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 000000000..e0d33e3e7 --- /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 000000000..138bc9969 --- /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 000000000..8b46eaee7 --- /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 000000000..3c24dd5f0 --- /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 000000000..515d66106 --- /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 000000000..0902f5cba --- /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 000000000..366fa911e --- /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 000000000..bc25314a4 --- /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 000000000..4f1128263 --- /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 000000000..4cf8cedf8 --- /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 000000000..5fbba9100 --- /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 000000000..16a673fde --- /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 000000000..202198f26 --- /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 000000000..e6599e130 --- /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 000000000..0e8ceef87 --- /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 000000000..45bcd8d2b --- /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 000000000..08e89b224 --- /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 000000000..edf53c144 --- /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 000000000..8bed75484 --- /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 000000000..7a73b4d8d --- /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/RealworldApplicationTests.java b/.framework/java/backend/src/test/java/io/spring/RealworldApplicationTests.java new file mode 100644 index 000000000..bf36bbdc1 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/RealworldApplicationTests.java @@ -0,0 +1,11 @@ +package io.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class RealworldApplicationTests { + + @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 000000000..3dd29f2fb --- /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 000000000..21b93f2d2 --- /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 000000000..08e8ece2e --- /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 000000000..e329dd82d --- /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 000000000..7f7ec5a77 --- /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 000000000..2cef06737 --- /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 000000000..705c98880 --- /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 000000000..f32091ecd --- /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 000000000..7d3b104b3 --- /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 000000000..9074f2edc --- /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 000000000..53d9842aa --- /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 000000000..ba8ebeaa7 --- /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 000000000..34ce502d3 --- /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 000000000..c74fd30c4 --- /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 000000000..cf57b424b --- /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 000000000..80ed81cb6 --- /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 000000000..8cc9a66eb --- /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 000000000..cb78cf1c6 --- /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 000000000..2689d03ca --- /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 000000000..ee415a7a9 --- /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 000000000..12929118a --- /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 000000000..39876111c --- /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 000000000..0e8a0eb36 --- /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 000000000..b2beb1917 --- /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 000000000..49515f248 --- /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 000000000..7b209f500 --- /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 000000000..21bb51610 --- /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 000000000..f9be249de --- /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 000000000..217f8c56c --- /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 000000000..19752ee20 --- /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 000000000..88517f33d --- /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 000000000..80b47d31c --- /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 000000000..0464a04e3 --- /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 000000000..e0b6bbbd3 --- /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" From 2a78782967b7124e8d8120417e408354bfdd10c4 Mon Sep 17 00:00:00 2001 From: Dar Malovani Date: Mon, 8 Jan 2024 17:05:50 +0200 Subject: [PATCH 2/5] feat: remove comment protection to be added in a quest --- .../java/backend/src/main/java/io/spring/api/CommentsApi.java | 3 --- .../src/main/java/io/spring/application/item/NewItemParam.java | 1 - 2 files changed, 4 deletions(-) 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 index c8215bc30..b4bc6b92f 100644 --- a/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java +++ b/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java @@ -75,9 +75,6 @@ public ResponseEntity deleteComment( .findById(item.getId(), commentId) .map( comment -> { - if (!AuthorizationService.canWriteComment(user, item, comment)) { - throw new NoAuthorizationException(); - } commentRepository.remove(comment); return ResponseEntity.noContent().build(); }) 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 index 9fb1848f9..89a4d84a7 100644 --- 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 @@ -21,7 +21,6 @@ public class NewItemParam { @NotBlank(message = "can't be empty") private String description; - @NotBlank(message = "can't be empty") private String image; private List tagList; From 441b95853a9d47b9096f13ae90520776f53ba339 Mon Sep 17 00:00:00 2001 From: Dar Malovani Date: Mon, 8 Jan 2024 17:47:37 +0200 Subject: [PATCH 3/5] feat: update docker file --- .framework/java/backend/Dockerfile | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.framework/java/backend/Dockerfile b/.framework/java/backend/Dockerfile index a61f3ee60..30bdf8095 100644 --- a/.framework/java/backend/Dockerfile +++ b/.framework/java/backend/Dockerfile @@ -1,15 +1,2 @@ -FROM openjdk:11 - -RUN curl https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh --output /wait-for-it.sh - -RUN chmod +x /wait-for-it.sh - -#COPY /backend /usr/src/backend - -WORKDIR /usr/src -#COPY backend ./backend -#COPY .wilco ./.wilco - -# Pre-install npm packages -WORKDIR /usr/src/backend +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-backend-java:latest From fdc01dd921fa9c7e9f5c8ffb1e4d059e6478b933 Mon Sep 17 00:00:00 2001 From: Dar Malovani Date: Tue, 9 Jan 2024 00:20:06 +0200 Subject: [PATCH 4/5] chore: realworld -> anythink market, readme change --- .framework/java/backend/README.md | 8 +++----- ...rldApplication.java => AnythinkMarketApplication.java} | 4 ++-- .../src/main/java/io/spring/JacksonCustomizations.java | 8 ++++---- ...ationTests.java => AythinkMarketApplicationTests.java} | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) rename .framework/java/backend/src/main/java/io/spring/{RealWorldApplication.java => AnythinkMarketApplication.java} (66%) rename .framework/java/backend/src/test/java/io/spring/{RealworldApplicationTests.java => AythinkMarketApplicationTests.java} (79%) diff --git a/.framework/java/backend/README.md b/.framework/java/backend/README.md index 64cb7fc74..acbddba5a 100644 --- a/.framework/java/backend/README.md +++ b/.framework/java/backend/README.md @@ -1,7 +1,5 @@ # Anythink Market Backend -> ### Spring boot + MyBatis codebase containing real world examples (CRUD, auth, advanced patterns, etc) - # How it works The application uses Spring Boot (Web, Mybatis). @@ -19,10 +17,10 @@ You'll need Java 11 installed. ./gradlew bootRun -To test that it works, open a browser tab at http://localhost:3000/tags . -Alternatively, you can run +To test that it works, open a browser tab at http://localhost:3000/api/tags +Alternatively, you can run: - curl http://localhost:3000/tags + curl http://localhost:3000/api/tags # Run test diff --git a/.framework/java/backend/src/main/java/io/spring/RealWorldApplication.java b/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java similarity index 66% rename from .framework/java/backend/src/main/java/io/spring/RealWorldApplication.java rename to .framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java index c9f34e1a5..8469390ca 100644 --- a/.framework/java/backend/src/main/java/io/spring/RealWorldApplication.java +++ b/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java @@ -4,9 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class RealWorldApplication { +public class AnythinkMarketApplication { public static void main(String[] args) { - SpringApplication.run(RealWorldApplication.class, 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 index 86fab0abe..874a46ee1 100644 --- a/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java +++ b/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java @@ -15,12 +15,12 @@ public class JacksonCustomizations { @Bean - public Module realWorldModules() { - return new RealWorldModules(); + public Module anythinkMarkerModules() { + return new AnythinkMarketModules(); } - public static class RealWorldModules extends SimpleModule { - public RealWorldModules() { + public static class AnythinkMarketModules extends SimpleModule { + public AnythinkMarketModules() { addSerializer(DateTime.class, new DateTimeSerializer()); } } diff --git a/.framework/java/backend/src/test/java/io/spring/RealworldApplicationTests.java b/.framework/java/backend/src/test/java/io/spring/AythinkMarketApplicationTests.java similarity index 79% rename from .framework/java/backend/src/test/java/io/spring/RealworldApplicationTests.java rename to .framework/java/backend/src/test/java/io/spring/AythinkMarketApplicationTests.java index bf36bbdc1..af9d938ae 100644 --- a/.framework/java/backend/src/test/java/io/spring/RealworldApplicationTests.java +++ b/.framework/java/backend/src/test/java/io/spring/AythinkMarketApplicationTests.java @@ -4,7 +4,7 @@ import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -public class RealworldApplicationTests { +public class AnythinkMarketApplicationTests { @Test public void contextLoads() {} From 1800c5d0a34f2170122afad8a120b5ed81848eb6 Mon Sep 17 00:00:00 2001 From: Dar Malovani Date: Tue, 9 Jan 2024 00:22:42 +0200 Subject: [PATCH 5/5] chore: remove dash from docker-compose --- .devcontainer/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index cf397d629..46d234d52 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