From 9bd55f781f3b2bc85793397edd692e0f6397231c Mon Sep 17 00:00:00 2001 From: "wilcoapp[bot]" <123394460+wilcoapp[bot]@users.noreply.github.com> Date: Sun, 22 Sep 2024 10:24:57 +0000 Subject: [PATCH] Initial commit --- .devcontainer/setup.sh | 18 + .../java/.devcontainer/devcontainer.json | 4 + .framework/java/backend/.gitignore | 26 + .framework/java/backend/Dockerfile | 2 + .framework/java/backend/Dockerfile.aws | 9 + .framework/java/backend/LICENSE | 21 + .framework/java/backend/README.md | 35 + .framework/java/backend/build.gradle | 79 + .../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 + .../io/spring/AnythinkMarketApplication.java | 12 + .../java/io/spring/JacksonCustomizations.java | 44 + .../main/java/io/spring/MyBatisConfig.java | 8 + .../backend/src/main/java/io/spring/Util.java | 7 + .../main/java/io/spring/api/CommentsApi.java | 94 + .../java/io/spring/api/CurrentUserApi.java | 58 + .../src/main/java/io/spring/api/ItemApi.java | 86 + .../java/io/spring/api/ItemFavoriteApi.java | 59 + .../src/main/java/io/spring/api/ItemsApi.java | 67 + .../src/main/java/io/spring/api/PingApi.java | 21 + .../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 | 86 + .../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 | 80 + .../application/CommentQueryService.java | 85 + .../application/CursorPageParameter.java | 40 + .../io/spring/application/CursorPager.java | 44 + .../io/spring/application/DateTimeCursor.java | 23 + .../spring/application/ItemQueryService.java | 185 + .../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 | 17 + .../application/item/ItemCommandService.java | 36 + .../spring/application/item/NewItemParam.java | 27 + .../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 | 74 + .../io/spring/core/item/ItemRepository.java | 14 + .../main/java/io/spring/core/item/Tag.java | 22 + .../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 + .../mybatis/DateTimeHandler.java | 44 + .../mybatis/mapper/CommentMapper.java | 14 + .../mybatis/mapper/ItemFavoriteMapper.java | 14 + .../mybatis/mapper/ItemMapper.java | 29 + .../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 | 59 + .../repository/MyBatisUserRepository.java | 60 + .../service/DefaultJwtService.java | 54 + .../service/SendEventService.java | 77 + .../resources/application-test.properties | 4 + .../src/main/resources/application.properties | 24 + .../db/migration/V1__create_tables.sql | 50 + .../main/resources/mapper/CommentMapper.xml | 35 + .../resources/mapper/CommentReadService.xml | 44 + .../resources/mapper/ItemFavoriteMapper.xml | 21 + .../mapper/ItemFavoritesReadService.xml | 30 + .../src/main/resources/mapper/ItemMapper.xml | 82 + .../main/resources/mapper/ItemReadService.xml | 160 + .../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 + .../AnythinkMarketApplicationTests.java | 13 + .../src/test/java/io/spring/TestHelper.java | 42 + .../java/io/spring/api/CommentsApiTest.java | 141 + .../io/spring/api/CurrentUserApiTest.java | 179 + .../test/java/io/spring/api/ItemApiTest.java | 222 + .../io/spring/api/ItemFavoriteApiTest.java | 103 + .../test/java/io/spring/api/ItemsApiTest.java | 174 + .../java/io/spring/api/ListItemApiTest.java | 72 + .../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 | 226 + .../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 | 33 + .../item/ItemRepositoryTransactionTest.java | 40 + .../item/MyBatisItemRepositoryTest.java | 72 + .../service/DefaultJwtServiceTest.java | 42 + .../user/MyBatisUserRepositoryTest.java | 73 + .framework/java/backend/start.sh | 6 + .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 | 60 + .../node/.devcontainer/devcontainer.json | 4 + .framework/node/backend/.gitignore | 37 + .framework/node/backend/Dockerfile | 1 + .framework/node/backend/Dockerfile.aws | 10 + .framework/node/backend/README.md | 22 + .framework/node/backend/app.js | 89 + .framework/node/backend/config/index.js | 3 + .framework/node/backend/config/passport.js | 18 + .framework/node/backend/lib/event.js | 25 + .framework/node/backend/models/Comment.js | 22 + .framework/node/backend/models/Item.js | 62 + .framework/node/backend/models/User.js | 130 + .framework/node/backend/package.json | 42 + .framework/node/backend/public/.keep | 0 .framework/node/backend/routes/api/index.js | 23 + .framework/node/backend/routes/api/items.js | 331 + .framework/node/backend/routes/api/ping.js | 19 + .../node/backend/routes/api/profiles.js | 53 + .framework/node/backend/routes/api/tags.js | 12 + .framework/node/backend/routes/api/users.js | 90 + .framework/node/backend/routes/auth.js | 27 + .framework/node/backend/routes/index.js | 13 + .framework/node/backend/scripts/seeds.js | 1 + .framework/node/backend/seeds.sh | 3 + .framework/node/backend/start.sh | 3 + .../node/backend/tests/api-tests.postman.json | 1900 +++ .../backend/tests/env-api-tests.postman.json | 14 + .framework/node/backend/yarn.lock | 3648 ++++++ .framework/node/charts/.helmignore | 23 + .framework/node/charts/Chart.yaml | 24 + .framework/node/charts/templates/_helpers.yml | 24 + .../anythink-backend-deployment.yaml | 55 + .../templates/anythink-backend-service.yaml | 14 + .../anythink-frontend-deployment.yaml | 55 + .../templates/anythink-frontend-service.yaml | 13 + .../charts/templates/database-deployment.yaml | 35 + .../node/charts/templates/database-pvc.yaml | 12 + .../charts/templates/database-service.yaml | 13 + .framework/node/charts/values.yaml | 68 + .framework/node/docker-compose.yml | 58 + .../python/.devcontainer/devcontainer.json | 4 + .framework/python/backend/.dockerignore | 20 + .framework/python/backend/.gitignore | 110 + .framework/python/backend/Dockerfile | 2 + .framework/python/backend/Dockerfile.aws | 15 + .framework/python/backend/LICENSE | 21 + .framework/python/backend/README.md | 30 + .framework/python/backend/alembic.ini | 36 + .framework/python/backend/app/__init__.py | 0 .framework/python/backend/app/api/__init__.py | 0 .../backend/app/api/dependencies/__init__.py | 0 .../app/api/dependencies/authentication.py | 111 + .../backend/app/api/dependencies/comments.py | 37 + .../backend/app/api/dependencies/database.py | 30 + .../backend/app/api/dependencies/items.py | 59 + .../backend/app/api/dependencies/profiles.py | 29 + .../python/backend/app/api/errors/__init__.py | 0 .../backend/app/api/errors/http_error.py | 7 + .../app/api/errors/validation_error.py | 28 + .../python/backend/app/api/routes/__init__.py | 0 .../python/backend/app/api/routes/api.py | 17 + .../backend/app/api/routes/authentication.py | 97 + .../python/backend/app/api/routes/comments.py | 67 + .../python/backend/app/api/routes/home.py | 11 + .../backend/app/api/routes/items/__init__.py | 0 .../backend/app/api/routes/items/api.py | 8 + .../app/api/routes/items/items_common.py | 104 + .../app/api/routes/items/items_resource.py | 122 + .../python/backend/app/api/routes/ping.py | 17 + .../python/backend/app/api/routes/profiles.py | 84 + .../python/backend/app/api/routes/tags.py | 15 + .../python/backend/app/api/routes/users.py | 73 + .../python/backend/app/core/__init__.py | 0 .framework/python/backend/app/core/config.py | 21 + .framework/python/backend/app/core/events.py | 25 + .framework/python/backend/app/core/logging.py | 25 + .../backend/app/core/settings/__init__.py | 0 .../python/backend/app/core/settings/app.py | 57 + .../python/backend/app/core/settings/base.py | 16 + .../backend/app/core/settings/development.py | 14 + .../backend/app/core/settings/production.py | 6 + .../python/backend/app/core/settings/test.py | 19 + .framework/python/backend/app/db/__init__.py | 0 .framework/python/backend/app/db/errors.py | 2 + .framework/python/backend/app/db/events.py | 29 + .../python/backend/app/db/migrations/env.py | 41 + .../backend/app/db/migrations/script.py.mako | 23 + .../versions/fdf8821871d7_main_tables.py | 217 + .../python/backend/app/db/queries/__init__.py | 0 .../python/backend/app/db/queries/queries.py | 5 + .../python/backend/app/db/queries/queries.pyi | 125 + .../backend/app/db/queries/sql/comments.sql | 41 + .../backend/app/db/queries/sql/items.sql | 123 + .../backend/app/db/queries/sql/profiles.sql | 38 + .../backend/app/db/queries/sql/tags.sql | 9 + .../backend/app/db/queries/sql/users.sql | 49 + .../python/backend/app/db/queries/tables.py | 75 + .../backend/app/db/repositories/__init__.py | 0 .../backend/app/db/repositories/base.py | 10 + .../backend/app/db/repositories/comments.py | 103 + .../backend/app/db/repositories/items.py | 353 + .../backend/app/db/repositories/profiles.py | 74 + .../backend/app/db/repositories/tags.py | 13 + .../backend/app/db/repositories/users.py | 81 + .framework/python/backend/app/db/seeds.py | 1 + .framework/python/backend/app/main.py | 47 + .../python/backend/app/models/__init__.py | 0 .../python/backend/app/models/common.py | 19 + .../backend/app/models/domain/__init__.py | 0 .../backend/app/models/domain/comments.py | 8 + .../python/backend/app/models/domain/items.py | 17 + .../backend/app/models/domain/profiles.py | 10 + .../backend/app/models/domain/rwmodel.py | 21 + .../python/backend/app/models/domain/users.py | 24 + .../backend/app/models/schemas/__init__.py | 0 .../backend/app/models/schemas/comments.py | 16 + .../backend/app/models/schemas/items.py | 46 + .../python/backend/app/models/schemas/jwt.py | 12 + .../backend/app/models/schemas/profiles.py | 7 + .../backend/app/models/schemas/rwschema.py | 6 + .../python/backend/app/models/schemas/tags.py | 7 + .../backend/app/models/schemas/users.py | 31 + .../python/backend/app/resources/__init__.py | 0 .../python/backend/app/resources/strings.py | 25 + .../python/backend/app/services/__init__.py | 0 .../backend/app/services/authentication.py | 20 + .../python/backend/app/services/event.py | 22 + .../python/backend/app/services/items.py | 23 + .framework/python/backend/app/services/jwt.py | 41 + .../python/backend/app/services/security.py | 16 + .framework/python/backend/poetry.lock | 2136 +++ .framework/python/backend/pyproject.toml | 72 + .framework/python/backend/requirements.txt | 43 + .framework/python/backend/runtime.txt | 1 + .framework/python/backend/scripts/format | 8 + .../python/backend/scripts/heroku_release.sh | 16 + .framework/python/backend/scripts/lint | 11 + .framework/python/backend/scripts/test | 6 + .../python/backend/scripts/test-cov-html | 6 + .framework/python/backend/seeds.sh | 3 + .framework/python/backend/setup.cfg | 88 + .framework/python/charts/.helmignore | 23 + .framework/python/charts/Chart.yaml | 24 + .../python/charts/templates/_helpers.yml | 24 + .../anythink-backend-deployment.yaml | 74 + .../templates/anythink-backend-service.yaml | 14 + .../anythink-frontend-deployment.yaml | 55 + .../templates/anythink-frontend-service.yaml | 13 + .../charts/templates/database-deployment.yaml | 44 + .../python/charts/templates/database-pvc.yaml | 12 + .../charts/templates/database-service.yaml | 13 + .framework/python/charts/values.yaml | 70 + .framework/python/docker-compose.yml | 67 + .../rails/.devcontainer/devcontainer.json | 4 + .framework/rails/backend/.gitignore | 30 + .framework/rails/backend/.ruby-version | 1 + .framework/rails/backend/Dockerfile | 1 + .framework/rails/backend/Dockerfile.aws | 12 + .framework/rails/backend/Gemfile | 69 + .framework/rails/backend/Gemfile.lock | 258 + .framework/rails/backend/README.md | 19 + .framework/rails/backend/Rakefile | 6 + .../backend/app/assets/config/manifest.js | 3 + .../rails/backend/app/assets/images/.keep | 0 .../backend/app/assets/javascripts/.keep | 0 .../backend/app/assets/stylesheets/.keep | 0 .../app/channels/application_cable/channel.rb | 4 + .../channels/application_cable/connection.rb | 4 + .../app/controllers/application_controller.rb | 48 + .../app/controllers/comments_controller.rb | 34 + .../backend/app/controllers/concerns/.keep | 0 .../app/controllers/favorites_controller.rb | 24 + .../app/controllers/follows_controller.rb | 21 + .../app/controllers/items_controller.rb | 98 + .../app/controllers/ping_controller.rb | 11 + .../app/controllers/profiles_controller.rb | 7 + .../controllers/registrations_controller.rb | 14 + .../app/controllers/sessions_controller.rb | 13 + .../app/controllers/tags_controller.rb | 7 + .../app/controllers/users_controller.rb | 21 + .../backend/app/helpers/application_helper.rb | 2 + .../rails/backend/app/jobs/application_job.rb | 2 + .../backend/app/mailers/application_mailer.rb | 4 + .../backend/app/models/application_record.rb | 3 + .../rails/backend/app/models/comment.rb | 8 + .../rails/backend/app/models/concerns/.keep | 0 .../rails/backend/app/models/favorite.rb | 6 + .framework/rails/backend/app/models/follow.rb | 14 + .framework/rails/backend/app/models/item.rb | 20 + .framework/rails/backend/app/models/user.rb | 40 + .../app/views/comments/_comment.json.jbuilder | 6 + .../app/views/comments/create.json.jbuilder | 5 + .../app/views/comments/index.json.jbuilder | 5 + .../devise/registrations/create.json.jbuilder | 5 + .../devise/sessions/create.json.jbuilder | 5 + .../app/views/items/_item.json.jbuilder | 9 + .../app/views/items/index.json.jbuilder | 7 + .../app/views/items/show.json.jbuilder | 5 + .../app/views/profiles/_profile.json.jbuilder | 5 + .../app/views/profiles/show.json.jbuilder | 5 + .../app/views/users/_user.json.jbuilder | 4 + .../app/views/users/show.json.jbuilder | 5 + .framework/rails/backend/bin/bundle | 3 + .../rails/backend/bin/heroku_release.sh | 25 + .framework/rails/backend/bin/rails | 9 + .framework/rails/backend/bin/rake | 9 + .framework/rails/backend/bin/setup | 36 + .framework/rails/backend/bin/spring | 17 + .framework/rails/backend/bin/update | 31 + .framework/rails/backend/bin/yarn | 11 + .framework/rails/backend/config.ru | 5 + .../rails/backend/config/application.rb | 22 + .framework/rails/backend/config/boot.rb | 4 + .framework/rails/backend/config/cable.yml | 10 + .../rails/backend/config/credentials.yml.enc | 1 + .framework/rails/backend/config/database.yml | 21 + .../rails/backend/config/environment.rb | 5 + .../config/environments/development.rb | 63 + .../backend/config/environments/production.rb | 93 + .../rails/backend/config/environments/test.rb | 46 + .../application_controller_renderer.rb | 8 + .../backend/config/initializers/assets.rb | 14 + .../initializers/backtrace_silencers.rb | 7 + .../initializers/content_security_policy.rb | 25 + .../config/initializers/cookies_serializer.rb | 5 + .../rails/backend/config/initializers/cors.rb | 9 + .../backend/config/initializers/devise.rb | 311 + .../initializers/filter_parameter_logging.rb | 4 + .../config/initializers/inflections.rb | 16 + .../initializers/json_param_key_transform.rb | 17 + .../backend/config/initializers/mime_types.rb | 4 + .../config/initializers/wrap_parameters.rb | 14 + .../backend/config/locales/devise.en.yml | 65 + .../rails/backend/config/locales/en.yml | 33 + .../backend/config/locales/responders.en.yml | 12 + .framework/rails/backend/config/puma.rb | 34 + .framework/rails/backend/config/routes.rb | 27 + .framework/rails/backend/config/secrets.yml | 26 + .../rails/backend/config/secrets.yml.enc | 1 + .framework/rails/backend/config/spring.rb | 6 + .framework/rails/backend/config/storage.yml | 34 + .../20210412045707_devise_create_users.rb | 43 + ...10412045739_add_profile_fields_to_users.rb | 10 + .../db/migrate/20210412052128_create_items.rb | 17 + ...on_migration.acts_as_taggable_on_engine.rb | 33 + ...ique_indices.acts_as_taggable_on_engine.rb | 22 + ...ache_to_tags.acts_as_taggable_on_engine.rb | 17 + ...ggable_index.acts_as_taggable_on_engine.rb | 12 + ...or_tag_names.acts_as_taggable_on_engine.rb | 12 + .../20210412055201_create_favorites.rb | 12 + .../migrate/20210412061113_create_comments.rb | 13 + ...210412061614_acts_as_follower_migration.rb | 19 + .framework/rails/backend/db/schema.rb | 110 + .framework/rails/backend/db/seeds.rb | 7 + .framework/rails/backend/lib/assets/.keep | 0 .framework/rails/backend/lib/event.rb | 19 + .framework/rails/backend/lib/tasks/.keep | 0 .framework/rails/backend/log/.keep | 0 .framework/rails/backend/public/404.html | 67 + .framework/rails/backend/public/422.html | 67 + .framework/rails/backend/public/500.html | 66 + .framework/rails/backend/public/favicon.ico | 0 .framework/rails/backend/public/robots.txt | 1 + .framework/rails/backend/seeds.sh | 3 + .framework/rails/backend/start.sh | 3 + .framework/rails/backend/start_rails.sh | 5 + .framework/rails/backend/vendor/.keep | 0 .framework/rails/charts/.helmignore | 23 + .framework/rails/charts/Chart.yaml | 24 + .../rails/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 | 42 + .../rails/charts/templates/database-pvc.yaml | 12 + .../charts/templates/database-service.yaml | 13 + .framework/rails/charts/values.yaml | 70 + .framework/rails/docker-compose.yml | 61 + .framework/react/frontend/.eslintignore | 2 + .framework/react/frontend/.gitignore | 16 + .framework/react/frontend/Dockerfile | 1 + .framework/react/frontend/Dockerfile.aws | 9 + .framework/react/frontend/jest.config.js | 8 + .framework/react/frontend/package.json | 74 + .../react/frontend/public/50precentoff.png | Bin 0 -> 58158 bytes .framework/react/frontend/public/favicon.ico | Bin 0 -> 15086 bytes .framework/react/frontend/public/index.html | 47 + .../react/frontend/public/placeholder.png | Bin 0 -> 6146 bytes .framework/react/frontend/public/style.css | 54 + .framework/react/frontend/public/sunray.jpeg | Bin 0 -> 46419 bytes .../react/frontend/public/verified_seller.svg | 18 + .framework/react/frontend/readme.md | 26 + .framework/react/frontend/src/agent.js | 98 + .../react/frontend/src/components/App.js | 81 + .../react/frontend/src/components/Editor.js | 176 + .../react/frontend/src/components/Header.js | 73 + .../frontend/src/components/Home/Banner.js | 19 + .../frontend/src/components/Home/MainView.js | 100 + .../frontend/src/components/Home/Tags.js | 40 + .../frontend/src/components/Home/index.js | 54 + .../frontend/src/components/Item/Comment.js | 42 + .../src/components/Item/CommentContainer.js | 46 + .../src/components/Item/CommentInput.js | 59 + .../src/components/Item/CommentList.js | 21 + .../src/components/Item/DeleteButton.js | 27 + .../src/components/Item/ItemActions.js | 36 + .../frontend/src/components/Item/ItemMeta.js | 30 + .../frontend/src/components/Item/index.js | 85 + .../src/components/Item/utils/ItemFetcher.js | 8 + .../react/frontend/src/components/ItemList.js | 35 + .../frontend/src/components/ItemPreview.js | 66 + .../frontend/src/components/ListErrors.js | 24 + .../frontend/src/components/ListPagination.js | 52 + .../react/frontend/src/components/Login.js | 121 + .../react/frontend/src/components/Profile.js | 172 + .../src/components/ProfileFavorites.js | 56 + .../react/frontend/src/components/Register.js | 148 + .../react/frontend/src/components/Settings.js | 142 + .../react/frontend/src/components/commons.js | 5 + .../frontend/src/constants/actionTypes.js | 37 + .framework/react/frontend/src/custom.scss | 61 + .../react/frontend/src/imgs/background.png | Bin 0 -> 1250693 bytes .framework/react/frontend/src/imgs/logo.png | Bin 0 -> 149049 bytes .../react/frontend/src/imgs/topbar_logo.png | Bin 0 -> 2321 bytes .framework/react/frontend/src/index.js | 18 + .framework/react/frontend/src/middleware.js | 65 + .framework/react/frontend/src/reducer.js | 20 + .../react/frontend/src/reducers/auth.js | 36 + .../react/frontend/src/reducers/common.js | 79 + .../react/frontend/src/reducers/editor.js | 56 + .../react/frontend/src/reducers/home.js | 17 + .../react/frontend/src/reducers/item.js | 38 + .../react/frontend/src/reducers/itemList.js | 88 + .../react/frontend/src/reducers/profile.js | 26 + .../react/frontend/src/reducers/settings.js | 27 + .framework/react/frontend/src/setupTests.js | 5 + .framework/react/frontend/src/store.js | 25 + .../src/tests/components/Header.test.js | 54 + .../__snapshots__/Header.test.js.snap | 119 + .../src/tests/item/CommentInput.test.js | 64 + .../__snapshots__/CommentInput.test.js.snap | 35 + .framework/react/frontend/start.sh | 3 + .framework/react/frontend/yarn.lock | 10689 ++++++++++++++++ .github/.workflows/k8s.yml | 157 + .github/pull_request_template.md | 3 + .github/workflows/test_e2e_java.yml | 60 + .github/workflows/test_e2e_lint.yml | 28 + .github/workflows/test_e2e_node.yml | 46 + .github/workflows/test_e2e_python.yml | 68 + .github/workflows/test_e2e_rails.yml | 60 + .github/workflows/test_frontend_react.yml | 35 + .gitignore | 37 + .vscode/settings.json | 3 + readme.md | 17 + tests/e2e/.eslintrc.js | 34 + tests/e2e/anytinkClient.js | 219 + tests/e2e/concurrent/health.test.js | 15 + tests/e2e/concurrent/items.test.js | 561 + tests/e2e/concurrent/profiles.test.js | 64 + tests/e2e/concurrent/tags.test.js | 36 + tests/e2e/concurrent/users.test.js | 152 + tests/e2e/jest.concurrent.config.js | 6 + tests/e2e/jest.sequential.config.js | 5 + tests/e2e/package.json | 35 + tests/e2e/sequential/events.test.js | 32 + tests/e2e/setup.js | 7 + tests/e2e/teardown.js | 5 + tests/e2e/utils.js | 58 + tests/e2e/wilcoEngine/mockWilcoEngine.js | 27 + tests/e2e/wilcoEngine/utils.js | 34 + tests/e2e/wilcoEngine/wilcoEngineEvents.js | 13 + tests/e2e/yarn.lock | 3809 ++++++ tests/frontend/global-setup.js | 5 + tests/frontend/package.json | 17 + tests/frontend/playwright.config.js | 67 + tests/frontend/requestValidator.js | 68 + tests/frontend/tests/editor.spec.js | 81 + tests/frontend/tests/signup.spec.js | 99 + tests/frontend/yarn.lock | 695 + tests/readme.md | 8 + tests/utils.js | 7 + 527 files changed, 42631 insertions(+) create mode 100755 .devcontainer/setup.sh create mode 100644 .framework/java/.devcontainer/devcontainer.json create mode 100644 .framework/java/backend/.gitignore create mode 100644 .framework/java/backend/Dockerfile create mode 100644 .framework/java/backend/Dockerfile.aws 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/AnythinkMarketApplication.java 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/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/PingApi.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/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/java/io/spring/infrastructure/service/SendEventService.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/test/java/io/spring/AnythinkMarketApplicationTests.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 100755 .framework/java/backend/start.sh 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 create mode 100644 .framework/node/.devcontainer/devcontainer.json create mode 100644 .framework/node/backend/.gitignore create mode 100644 .framework/node/backend/Dockerfile create mode 100644 .framework/node/backend/Dockerfile.aws create mode 100644 .framework/node/backend/README.md create mode 100644 .framework/node/backend/app.js create mode 100644 .framework/node/backend/config/index.js create mode 100644 .framework/node/backend/config/passport.js create mode 100644 .framework/node/backend/lib/event.js create mode 100644 .framework/node/backend/models/Comment.js create mode 100644 .framework/node/backend/models/Item.js create mode 100644 .framework/node/backend/models/User.js create mode 100644 .framework/node/backend/package.json create mode 100644 .framework/node/backend/public/.keep create mode 100644 .framework/node/backend/routes/api/index.js create mode 100644 .framework/node/backend/routes/api/items.js create mode 100644 .framework/node/backend/routes/api/ping.js create mode 100644 .framework/node/backend/routes/api/profiles.js create mode 100644 .framework/node/backend/routes/api/tags.js create mode 100644 .framework/node/backend/routes/api/users.js create mode 100644 .framework/node/backend/routes/auth.js create mode 100644 .framework/node/backend/routes/index.js create mode 100644 .framework/node/backend/scripts/seeds.js create mode 100755 .framework/node/backend/seeds.sh create mode 100755 .framework/node/backend/start.sh create mode 100644 .framework/node/backend/tests/api-tests.postman.json create mode 100644 .framework/node/backend/tests/env-api-tests.postman.json create mode 100644 .framework/node/backend/yarn.lock create mode 100644 .framework/node/charts/.helmignore create mode 100644 .framework/node/charts/Chart.yaml create mode 100644 .framework/node/charts/templates/_helpers.yml create mode 100644 .framework/node/charts/templates/anythink-backend-deployment.yaml create mode 100644 .framework/node/charts/templates/anythink-backend-service.yaml create mode 100644 .framework/node/charts/templates/anythink-frontend-deployment.yaml create mode 100644 .framework/node/charts/templates/anythink-frontend-service.yaml create mode 100644 .framework/node/charts/templates/database-deployment.yaml create mode 100644 .framework/node/charts/templates/database-pvc.yaml create mode 100644 .framework/node/charts/templates/database-service.yaml create mode 100644 .framework/node/charts/values.yaml create mode 100644 .framework/node/docker-compose.yml create mode 100644 .framework/python/.devcontainer/devcontainer.json create mode 100644 .framework/python/backend/.dockerignore create mode 100644 .framework/python/backend/.gitignore create mode 100644 .framework/python/backend/Dockerfile create mode 100644 .framework/python/backend/Dockerfile.aws create mode 100644 .framework/python/backend/LICENSE create mode 100644 .framework/python/backend/README.md create mode 100644 .framework/python/backend/alembic.ini create mode 100644 .framework/python/backend/app/__init__.py create mode 100644 .framework/python/backend/app/api/__init__.py create mode 100644 .framework/python/backend/app/api/dependencies/__init__.py create mode 100644 .framework/python/backend/app/api/dependencies/authentication.py create mode 100644 .framework/python/backend/app/api/dependencies/comments.py create mode 100644 .framework/python/backend/app/api/dependencies/database.py create mode 100644 .framework/python/backend/app/api/dependencies/items.py create mode 100644 .framework/python/backend/app/api/dependencies/profiles.py create mode 100644 .framework/python/backend/app/api/errors/__init__.py create mode 100644 .framework/python/backend/app/api/errors/http_error.py create mode 100644 .framework/python/backend/app/api/errors/validation_error.py create mode 100644 .framework/python/backend/app/api/routes/__init__.py create mode 100644 .framework/python/backend/app/api/routes/api.py create mode 100644 .framework/python/backend/app/api/routes/authentication.py create mode 100644 .framework/python/backend/app/api/routes/comments.py create mode 100644 .framework/python/backend/app/api/routes/home.py create mode 100644 .framework/python/backend/app/api/routes/items/__init__.py create mode 100644 .framework/python/backend/app/api/routes/items/api.py create mode 100644 .framework/python/backend/app/api/routes/items/items_common.py create mode 100644 .framework/python/backend/app/api/routes/items/items_resource.py create mode 100644 .framework/python/backend/app/api/routes/ping.py create mode 100644 .framework/python/backend/app/api/routes/profiles.py create mode 100644 .framework/python/backend/app/api/routes/tags.py create mode 100644 .framework/python/backend/app/api/routes/users.py create mode 100644 .framework/python/backend/app/core/__init__.py create mode 100644 .framework/python/backend/app/core/config.py create mode 100644 .framework/python/backend/app/core/events.py create mode 100644 .framework/python/backend/app/core/logging.py create mode 100644 .framework/python/backend/app/core/settings/__init__.py create mode 100644 .framework/python/backend/app/core/settings/app.py create mode 100644 .framework/python/backend/app/core/settings/base.py create mode 100644 .framework/python/backend/app/core/settings/development.py create mode 100644 .framework/python/backend/app/core/settings/production.py create mode 100644 .framework/python/backend/app/core/settings/test.py create mode 100644 .framework/python/backend/app/db/__init__.py create mode 100644 .framework/python/backend/app/db/errors.py create mode 100644 .framework/python/backend/app/db/events.py create mode 100644 .framework/python/backend/app/db/migrations/env.py create mode 100644 .framework/python/backend/app/db/migrations/script.py.mako create mode 100644 .framework/python/backend/app/db/migrations/versions/fdf8821871d7_main_tables.py create mode 100644 .framework/python/backend/app/db/queries/__init__.py create mode 100644 .framework/python/backend/app/db/queries/queries.py create mode 100644 .framework/python/backend/app/db/queries/queries.pyi create mode 100644 .framework/python/backend/app/db/queries/sql/comments.sql create mode 100644 .framework/python/backend/app/db/queries/sql/items.sql create mode 100644 .framework/python/backend/app/db/queries/sql/profiles.sql create mode 100644 .framework/python/backend/app/db/queries/sql/tags.sql create mode 100644 .framework/python/backend/app/db/queries/sql/users.sql create mode 100644 .framework/python/backend/app/db/queries/tables.py create mode 100644 .framework/python/backend/app/db/repositories/__init__.py create mode 100644 .framework/python/backend/app/db/repositories/base.py create mode 100644 .framework/python/backend/app/db/repositories/comments.py create mode 100644 .framework/python/backend/app/db/repositories/items.py create mode 100644 .framework/python/backend/app/db/repositories/profiles.py create mode 100644 .framework/python/backend/app/db/repositories/tags.py create mode 100644 .framework/python/backend/app/db/repositories/users.py create mode 100644 .framework/python/backend/app/db/seeds.py create mode 100644 .framework/python/backend/app/main.py create mode 100644 .framework/python/backend/app/models/__init__.py create mode 100644 .framework/python/backend/app/models/common.py create mode 100644 .framework/python/backend/app/models/domain/__init__.py create mode 100644 .framework/python/backend/app/models/domain/comments.py create mode 100644 .framework/python/backend/app/models/domain/items.py create mode 100644 .framework/python/backend/app/models/domain/profiles.py create mode 100644 .framework/python/backend/app/models/domain/rwmodel.py create mode 100644 .framework/python/backend/app/models/domain/users.py create mode 100644 .framework/python/backend/app/models/schemas/__init__.py create mode 100644 .framework/python/backend/app/models/schemas/comments.py create mode 100644 .framework/python/backend/app/models/schemas/items.py create mode 100644 .framework/python/backend/app/models/schemas/jwt.py create mode 100644 .framework/python/backend/app/models/schemas/profiles.py create mode 100644 .framework/python/backend/app/models/schemas/rwschema.py create mode 100644 .framework/python/backend/app/models/schemas/tags.py create mode 100644 .framework/python/backend/app/models/schemas/users.py create mode 100644 .framework/python/backend/app/resources/__init__.py create mode 100644 .framework/python/backend/app/resources/strings.py create mode 100644 .framework/python/backend/app/services/__init__.py create mode 100644 .framework/python/backend/app/services/authentication.py create mode 100644 .framework/python/backend/app/services/event.py create mode 100644 .framework/python/backend/app/services/items.py create mode 100644 .framework/python/backend/app/services/jwt.py create mode 100644 .framework/python/backend/app/services/security.py create mode 100644 .framework/python/backend/poetry.lock create mode 100644 .framework/python/backend/pyproject.toml create mode 100644 .framework/python/backend/requirements.txt create mode 100644 .framework/python/backend/runtime.txt create mode 100755 .framework/python/backend/scripts/format create mode 100755 .framework/python/backend/scripts/heroku_release.sh create mode 100755 .framework/python/backend/scripts/lint create mode 100755 .framework/python/backend/scripts/test create mode 100755 .framework/python/backend/scripts/test-cov-html create mode 100755 .framework/python/backend/seeds.sh create mode 100644 .framework/python/backend/setup.cfg create mode 100644 .framework/python/charts/.helmignore create mode 100644 .framework/python/charts/Chart.yaml create mode 100644 .framework/python/charts/templates/_helpers.yml create mode 100644 .framework/python/charts/templates/anythink-backend-deployment.yaml create mode 100644 .framework/python/charts/templates/anythink-backend-service.yaml create mode 100644 .framework/python/charts/templates/anythink-frontend-deployment.yaml create mode 100644 .framework/python/charts/templates/anythink-frontend-service.yaml create mode 100644 .framework/python/charts/templates/database-deployment.yaml create mode 100644 .framework/python/charts/templates/database-pvc.yaml create mode 100644 .framework/python/charts/templates/database-service.yaml create mode 100644 .framework/python/charts/values.yaml create mode 100644 .framework/python/docker-compose.yml create mode 100644 .framework/rails/.devcontainer/devcontainer.json create mode 100644 .framework/rails/backend/.gitignore create mode 100644 .framework/rails/backend/.ruby-version create mode 100644 .framework/rails/backend/Dockerfile create mode 100644 .framework/rails/backend/Dockerfile.aws create mode 100644 .framework/rails/backend/Gemfile create mode 100644 .framework/rails/backend/Gemfile.lock create mode 100644 .framework/rails/backend/README.md create mode 100644 .framework/rails/backend/Rakefile create mode 100644 .framework/rails/backend/app/assets/config/manifest.js create mode 100644 .framework/rails/backend/app/assets/images/.keep create mode 100644 .framework/rails/backend/app/assets/javascripts/.keep create mode 100644 .framework/rails/backend/app/assets/stylesheets/.keep create mode 100644 .framework/rails/backend/app/channels/application_cable/channel.rb create mode 100644 .framework/rails/backend/app/channels/application_cable/connection.rb create mode 100644 .framework/rails/backend/app/controllers/application_controller.rb create mode 100644 .framework/rails/backend/app/controllers/comments_controller.rb create mode 100644 .framework/rails/backend/app/controllers/concerns/.keep create mode 100644 .framework/rails/backend/app/controllers/favorites_controller.rb create mode 100644 .framework/rails/backend/app/controllers/follows_controller.rb create mode 100644 .framework/rails/backend/app/controllers/items_controller.rb create mode 100644 .framework/rails/backend/app/controllers/ping_controller.rb create mode 100644 .framework/rails/backend/app/controllers/profiles_controller.rb create mode 100644 .framework/rails/backend/app/controllers/registrations_controller.rb create mode 100644 .framework/rails/backend/app/controllers/sessions_controller.rb create mode 100644 .framework/rails/backend/app/controllers/tags_controller.rb create mode 100644 .framework/rails/backend/app/controllers/users_controller.rb create mode 100644 .framework/rails/backend/app/helpers/application_helper.rb create mode 100644 .framework/rails/backend/app/jobs/application_job.rb create mode 100644 .framework/rails/backend/app/mailers/application_mailer.rb create mode 100644 .framework/rails/backend/app/models/application_record.rb create mode 100644 .framework/rails/backend/app/models/comment.rb create mode 100644 .framework/rails/backend/app/models/concerns/.keep create mode 100644 .framework/rails/backend/app/models/favorite.rb create mode 100644 .framework/rails/backend/app/models/follow.rb create mode 100644 .framework/rails/backend/app/models/item.rb create mode 100644 .framework/rails/backend/app/models/user.rb create mode 100644 .framework/rails/backend/app/views/comments/_comment.json.jbuilder create mode 100644 .framework/rails/backend/app/views/comments/create.json.jbuilder create mode 100644 .framework/rails/backend/app/views/comments/index.json.jbuilder create mode 100644 .framework/rails/backend/app/views/devise/registrations/create.json.jbuilder create mode 100644 .framework/rails/backend/app/views/devise/sessions/create.json.jbuilder create mode 100644 .framework/rails/backend/app/views/items/_item.json.jbuilder create mode 100644 .framework/rails/backend/app/views/items/index.json.jbuilder create mode 100644 .framework/rails/backend/app/views/items/show.json.jbuilder create mode 100644 .framework/rails/backend/app/views/profiles/_profile.json.jbuilder create mode 100644 .framework/rails/backend/app/views/profiles/show.json.jbuilder create mode 100644 .framework/rails/backend/app/views/users/_user.json.jbuilder create mode 100644 .framework/rails/backend/app/views/users/show.json.jbuilder create mode 100755 .framework/rails/backend/bin/bundle create mode 100755 .framework/rails/backend/bin/heroku_release.sh create mode 100755 .framework/rails/backend/bin/rails create mode 100755 .framework/rails/backend/bin/rake create mode 100755 .framework/rails/backend/bin/setup create mode 100755 .framework/rails/backend/bin/spring create mode 100755 .framework/rails/backend/bin/update create mode 100755 .framework/rails/backend/bin/yarn create mode 100644 .framework/rails/backend/config.ru create mode 100644 .framework/rails/backend/config/application.rb create mode 100644 .framework/rails/backend/config/boot.rb create mode 100644 .framework/rails/backend/config/cable.yml create mode 100644 .framework/rails/backend/config/credentials.yml.enc create mode 100644 .framework/rails/backend/config/database.yml create mode 100644 .framework/rails/backend/config/environment.rb create mode 100644 .framework/rails/backend/config/environments/development.rb create mode 100644 .framework/rails/backend/config/environments/production.rb create mode 100644 .framework/rails/backend/config/environments/test.rb create mode 100644 .framework/rails/backend/config/initializers/application_controller_renderer.rb create mode 100644 .framework/rails/backend/config/initializers/assets.rb create mode 100644 .framework/rails/backend/config/initializers/backtrace_silencers.rb create mode 100644 .framework/rails/backend/config/initializers/content_security_policy.rb create mode 100644 .framework/rails/backend/config/initializers/cookies_serializer.rb create mode 100644 .framework/rails/backend/config/initializers/cors.rb create mode 100644 .framework/rails/backend/config/initializers/devise.rb create mode 100644 .framework/rails/backend/config/initializers/filter_parameter_logging.rb create mode 100644 .framework/rails/backend/config/initializers/inflections.rb create mode 100644 .framework/rails/backend/config/initializers/json_param_key_transform.rb create mode 100644 .framework/rails/backend/config/initializers/mime_types.rb create mode 100644 .framework/rails/backend/config/initializers/wrap_parameters.rb create mode 100644 .framework/rails/backend/config/locales/devise.en.yml create mode 100644 .framework/rails/backend/config/locales/en.yml create mode 100644 .framework/rails/backend/config/locales/responders.en.yml create mode 100644 .framework/rails/backend/config/puma.rb create mode 100644 .framework/rails/backend/config/routes.rb create mode 100644 .framework/rails/backend/config/secrets.yml create mode 100644 .framework/rails/backend/config/secrets.yml.enc create mode 100644 .framework/rails/backend/config/spring.rb create mode 100644 .framework/rails/backend/config/storage.yml create mode 100644 .framework/rails/backend/db/migrate/20210412045707_devise_create_users.rb create mode 100644 .framework/rails/backend/db/migrate/20210412045739_add_profile_fields_to_users.rb create mode 100644 .framework/rails/backend/db/migrate/20210412052128_create_items.rb create mode 100644 .framework/rails/backend/db/migrate/20210412054809_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb create mode 100644 .framework/rails/backend/db/migrate/20210412054810_add_missing_unique_indices.acts_as_taggable_on_engine.rb create mode 100644 .framework/rails/backend/db/migrate/20210412054811_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb create mode 100644 .framework/rails/backend/db/migrate/20210412054812_add_missing_taggable_index.acts_as_taggable_on_engine.rb create mode 100644 .framework/rails/backend/db/migrate/20210412054813_change_collation_for_tag_names.acts_as_taggable_on_engine.rb create mode 100644 .framework/rails/backend/db/migrate/20210412055201_create_favorites.rb create mode 100644 .framework/rails/backend/db/migrate/20210412061113_create_comments.rb create mode 100644 .framework/rails/backend/db/migrate/20210412061614_acts_as_follower_migration.rb create mode 100644 .framework/rails/backend/db/schema.rb create mode 100644 .framework/rails/backend/db/seeds.rb create mode 100644 .framework/rails/backend/lib/assets/.keep create mode 100644 .framework/rails/backend/lib/event.rb create mode 100644 .framework/rails/backend/lib/tasks/.keep create mode 100644 .framework/rails/backend/log/.keep create mode 100644 .framework/rails/backend/public/404.html create mode 100644 .framework/rails/backend/public/422.html create mode 100644 .framework/rails/backend/public/500.html create mode 100644 .framework/rails/backend/public/favicon.ico create mode 100644 .framework/rails/backend/public/robots.txt create mode 100755 .framework/rails/backend/seeds.sh create mode 100755 .framework/rails/backend/start.sh create mode 100755 .framework/rails/backend/start_rails.sh create mode 100644 .framework/rails/backend/vendor/.keep create mode 100644 .framework/rails/charts/.helmignore create mode 100644 .framework/rails/charts/Chart.yaml create mode 100644 .framework/rails/charts/templates/_helpers.yml create mode 100644 .framework/rails/charts/templates/anythink-backend-deployment.yaml create mode 100644 .framework/rails/charts/templates/anythink-backend-service.yaml create mode 100644 .framework/rails/charts/templates/anythink-frontend-deployment.yaml create mode 100644 .framework/rails/charts/templates/anythink-frontend-service.yaml create mode 100644 .framework/rails/charts/templates/database-deployment.yaml create mode 100644 .framework/rails/charts/templates/database-pvc.yaml create mode 100644 .framework/rails/charts/templates/database-service.yaml create mode 100644 .framework/rails/charts/values.yaml create mode 100644 .framework/rails/docker-compose.yml create mode 100644 .framework/react/frontend/.eslintignore create mode 100644 .framework/react/frontend/.gitignore create mode 100644 .framework/react/frontend/Dockerfile create mode 100644 .framework/react/frontend/Dockerfile.aws create mode 100644 .framework/react/frontend/jest.config.js create mode 100644 .framework/react/frontend/package.json create mode 100644 .framework/react/frontend/public/50precentoff.png create mode 100644 .framework/react/frontend/public/favicon.ico create mode 100644 .framework/react/frontend/public/index.html create mode 100644 .framework/react/frontend/public/placeholder.png create mode 100644 .framework/react/frontend/public/style.css create mode 100644 .framework/react/frontend/public/sunray.jpeg create mode 100644 .framework/react/frontend/public/verified_seller.svg create mode 100644 .framework/react/frontend/readme.md create mode 100644 .framework/react/frontend/src/agent.js create mode 100644 .framework/react/frontend/src/components/App.js create mode 100644 .framework/react/frontend/src/components/Editor.js create mode 100644 .framework/react/frontend/src/components/Header.js create mode 100644 .framework/react/frontend/src/components/Home/Banner.js create mode 100644 .framework/react/frontend/src/components/Home/MainView.js create mode 100644 .framework/react/frontend/src/components/Home/Tags.js create mode 100644 .framework/react/frontend/src/components/Home/index.js create mode 100644 .framework/react/frontend/src/components/Item/Comment.js create mode 100644 .framework/react/frontend/src/components/Item/CommentContainer.js create mode 100644 .framework/react/frontend/src/components/Item/CommentInput.js create mode 100644 .framework/react/frontend/src/components/Item/CommentList.js create mode 100644 .framework/react/frontend/src/components/Item/DeleteButton.js create mode 100644 .framework/react/frontend/src/components/Item/ItemActions.js create mode 100644 .framework/react/frontend/src/components/Item/ItemMeta.js create mode 100644 .framework/react/frontend/src/components/Item/index.js create mode 100644 .framework/react/frontend/src/components/Item/utils/ItemFetcher.js create mode 100644 .framework/react/frontend/src/components/ItemList.js create mode 100644 .framework/react/frontend/src/components/ItemPreview.js create mode 100644 .framework/react/frontend/src/components/ListErrors.js create mode 100644 .framework/react/frontend/src/components/ListPagination.js create mode 100644 .framework/react/frontend/src/components/Login.js create mode 100644 .framework/react/frontend/src/components/Profile.js create mode 100644 .framework/react/frontend/src/components/ProfileFavorites.js create mode 100644 .framework/react/frontend/src/components/Register.js create mode 100644 .framework/react/frontend/src/components/Settings.js create mode 100644 .framework/react/frontend/src/components/commons.js create mode 100644 .framework/react/frontend/src/constants/actionTypes.js create mode 100644 .framework/react/frontend/src/custom.scss create mode 100644 .framework/react/frontend/src/imgs/background.png create mode 100644 .framework/react/frontend/src/imgs/logo.png create mode 100644 .framework/react/frontend/src/imgs/topbar_logo.png create mode 100644 .framework/react/frontend/src/index.js create mode 100644 .framework/react/frontend/src/middleware.js create mode 100644 .framework/react/frontend/src/reducer.js create mode 100644 .framework/react/frontend/src/reducers/auth.js create mode 100644 .framework/react/frontend/src/reducers/common.js create mode 100644 .framework/react/frontend/src/reducers/editor.js create mode 100644 .framework/react/frontend/src/reducers/home.js create mode 100644 .framework/react/frontend/src/reducers/item.js create mode 100644 .framework/react/frontend/src/reducers/itemList.js create mode 100644 .framework/react/frontend/src/reducers/profile.js create mode 100644 .framework/react/frontend/src/reducers/settings.js create mode 100644 .framework/react/frontend/src/setupTests.js create mode 100644 .framework/react/frontend/src/store.js create mode 100644 .framework/react/frontend/src/tests/components/Header.test.js create mode 100644 .framework/react/frontend/src/tests/components/__snapshots__/Header.test.js.snap create mode 100644 .framework/react/frontend/src/tests/item/CommentInput.test.js create mode 100644 .framework/react/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap create mode 100755 .framework/react/frontend/start.sh create mode 100644 .framework/react/frontend/yarn.lock create mode 100644 .github/.workflows/k8s.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/test_e2e_java.yml create mode 100644 .github/workflows/test_e2e_lint.yml create mode 100644 .github/workflows/test_e2e_node.yml create mode 100644 .github/workflows/test_e2e_python.yml create mode 100644 .github/workflows/test_e2e_rails.yml create mode 100644 .github/workflows/test_frontend_react.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 readme.md create mode 100644 tests/e2e/.eslintrc.js create mode 100644 tests/e2e/anytinkClient.js create mode 100644 tests/e2e/concurrent/health.test.js create mode 100644 tests/e2e/concurrent/items.test.js create mode 100644 tests/e2e/concurrent/profiles.test.js create mode 100644 tests/e2e/concurrent/tags.test.js create mode 100644 tests/e2e/concurrent/users.test.js create mode 100644 tests/e2e/jest.concurrent.config.js create mode 100644 tests/e2e/jest.sequential.config.js create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/sequential/events.test.js create mode 100644 tests/e2e/setup.js create mode 100644 tests/e2e/teardown.js create mode 100644 tests/e2e/utils.js create mode 100644 tests/e2e/wilcoEngine/mockWilcoEngine.js create mode 100644 tests/e2e/wilcoEngine/utils.js create mode 100644 tests/e2e/wilcoEngine/wilcoEngineEvents.js create mode 100644 tests/e2e/yarn.lock create mode 100644 tests/frontend/global-setup.js create mode 100644 tests/frontend/package.json create mode 100644 tests/frontend/playwright.config.js create mode 100644 tests/frontend/requestValidator.js create mode 100644 tests/frontend/tests/editor.spec.js create mode 100644 tests/frontend/tests/signup.spec.js create mode 100644 tests/frontend/yarn.lock create mode 100644 tests/readme.md create mode 100644 tests/utils.js diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100755 index 0000000..5a6cfc3 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,18 @@ +WILCO_ID="`cat .wilco`" +CODESPACE_BACKEND_HOST=$(curl -s "${ENGINE_BASE_URL}/api/v1/codespace/backendHost?codespaceName=${CODESPACE_NAME}&portForwarding=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}" | jq -r '.codespaceBackendHost') +CODESPACE_BACKEND_URL="https://${CODESPACE_BACKEND_HOST}" +export ENGINE_EVENT_ENDPOINT="${ENGINE_BASE_URL}/users/${WILCO_ID}/event" + +# Update engine that codespace started for user +curl -L -X POST "${ENGINE_EVENT_ENDPOINT}" -H "Content-Type: application/json" --data-raw "{ \"event\": \"github_codespace_started\" }" + +# Export backend envs when in codespaces +echo "export CODESPACE_BACKEND_HOST=\"${CODESPACE_BACKEND_HOST}\"" >> ~/.bashrc +echo "export CODESPACE_BACKEND_URL=\"${CODESPACE_BACKEND_URL}\"" >> ~/.bashrc +echo "export CODESPACE_WDS_SOCKET_PORT=443" >> ~/.bashrc + +# Export welcome prompt in bash: +echo "printf \"\n\n☁️☁️☁️️ Anythink: Develop in the Cloud ☁️☁️☁️\n\"" >> ~/.bashrc +echo "printf \"\n\x1b[31m \x1b[1m👉 Type: \\\`docker compose up\\\` to run the project. 👈\n\n\"" >> ~/.bashrc + +nohup bash -c "cd /wilco-agent && node agent.js &" >> /tmp/agent.log 2>&1 diff --git a/.framework/java/.devcontainer/devcontainer.json b/.framework/java/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5e6dee5 --- /dev/null +++ b/.framework/java/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Anythink Development Container", + "image": "public.ecr.aws/v0a2l7y2/wilco/anythink-devcontainer-java:latest" +} diff --git a/.framework/java/backend/.gitignore b/.framework/java/backend/.gitignore new file mode 100644 index 0000000..dfcbb12 --- /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 0000000..30bdf80 --- /dev/null +++ b/.framework/java/backend/Dockerfile @@ -0,0 +1,2 @@ +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-backend-java:latest + diff --git a/.framework/java/backend/Dockerfile.aws b/.framework/java/backend/Dockerfile.aws new file mode 100644 index 0000000..e15ec67 --- /dev/null +++ b/.framework/java/backend/Dockerfile.aws @@ -0,0 +1,9 @@ +FROM openjdk:21 + +WORKDIR /usr/src +COPY backend ./backend +COPY .wilco ./.wilco + +# Pre-install packages +WORKDIR /usr/src/backend +RUN ./gradlew build diff --git a/.framework/java/backend/LICENSE b/.framework/java/backend/LICENSE new file mode 100644 index 0000000..a6fd2b7 --- /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 0000000..52e4265 --- /dev/null +++ b/.framework/java/backend/README.md @@ -0,0 +1,35 @@ +# Anythink Market Backend + +# How it works + +The application uses Spring Boot (Web, Mybatis). + +And the code is organized as this: + +1. `api` is the web layer implemented by Spring MVC +2. `core` is the business model including entities and services +3. `application` is the high-level services for querying the data transfer objects +4. `infrastructure` contains all the implementation classes as the technique details + +# Getting started + +You'll need Java 21 installed. + + ./gradlew bootRun + +To test that it works, open a browser tab at http://localhost:3000/api/tags +Alternatively, you can run: + + curl http://localhost:3000/api/tags + +# Run test + +The repository contains a lot of test cases to cover both api test and repository test. + + ./gradlew test + +# Code format + +Use spotless for code format. + + ./gradlew spotlessJavaApply diff --git a/.framework/java/backend/build.gradle b/.framework/java/backend/build.gradle new file mode 100644 index 0000000..2f04f67 --- /dev/null +++ b/.framework/java/backend/build.gradle @@ -0,0 +1,79 @@ +plugins { + id 'org.springframework.boot' version '3.2.2' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'java' + id "com.diffplug.spotless" version "6.25.0" +} + +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '21' +targetCompatibility = '21' + +spotless { + java { + target project.fileTree(project.rootDir) { + include '**/*.java' + exclude 'build/generated/**/*.*', 'build/generated-examples/**/*.*' + } + googleJavaFormat() + } +} + +repositories { + mavenCentral() +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +tasks.named("spotlessJava").configure { dependsOn("processResources") } +tasks.named("spotlessJava").configure { dependsOn("compileJava") } +tasks.named("spotlessJava").configure { dependsOn("compileTestJava") } +spotlessJava.dependsOn test + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'jakarta.validation:jakarta.validation-api:3.0.2' + implementation 'org.springframework.boot:spring-boot-starter-hateoas' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.security:spring-security-web' + implementation 'org.springframework.security:spring-security-config' + implementation 'com.squareup.okhttp3:okhttp:4.9.1' + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' + 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' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.1' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'io.rest-assured:rest-assured:5.4.0' + testImplementation 'io.rest-assured:json-path:5.4.0' + testImplementation 'io.rest-assured:xml-path:5.4.0' + testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0' + 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:3.0.3' + testImplementation 'com.h2database:h2:1.4.200' +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.named('clean') { + doFirst { + delete './dev.db' + } +} 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 0000000..a595206 --- /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-8.5-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 0000000..4f906e0 --- /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 0000000..107acd3 --- /dev/null +++ b/.framework/java/backend/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java b/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java new file mode 100644 index 0000000..8469390 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/AnythinkMarketApplication.java @@ -0,0 +1,12 @@ +package io.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AnythinkMarketApplication { + + public static void main(String[] args) { + SpringApplication.run(AnythinkMarketApplication.class, args); + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java b/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java new file mode 100644 index 0000000..874a46e --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/JacksonCustomizations.java @@ -0,0 +1,44 @@ +package io.spring; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; +import org.joda.time.DateTime; +import org.joda.time.format.ISODateTimeFormat; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonCustomizations { + + @Bean + public Module anythinkMarkerModules() { + return new AnythinkMarketModules(); + } + + public static class AnythinkMarketModules extends SimpleModule { + public AnythinkMarketModules() { + addSerializer(DateTime.class, new DateTimeSerializer()); + } + } + + public static class DateTimeSerializer extends StdSerializer { + + protected DateTimeSerializer() { + super(DateTime.class); + } + + @Override + public void serialize(DateTime value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (value == null) { + gen.writeNull(); + } else { + gen.writeString(ISODateTimeFormat.dateTime().withZoneUTC().print(value)); + } + } + } +} diff --git a/.framework/java/backend/src/main/java/io/spring/MyBatisConfig.java b/.framework/java/backend/src/main/java/io/spring/MyBatisConfig.java new file mode 100644 index 0000000..d1f741c --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/MyBatisConfig.java @@ -0,0 +1,8 @@ +package io.spring; + +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Configuration +@EnableTransactionManagement +public class MyBatisConfig {} diff --git a/.framework/java/backend/src/main/java/io/spring/Util.java b/.framework/java/backend/src/main/java/io/spring/Util.java new file mode 100644 index 0000000..d2512ac --- /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 0000000..63e4a0a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/CommentsApi.java @@ -0,0 +1,94 @@ +package io.spring.api; + +import com.fasterxml.jackson.annotation.JsonRootName; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.CommentQueryService; +import io.spring.application.data.CommentData; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.user.User; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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 = "/api/items/{slug}/comments") +@AllArgsConstructor +public class CommentsApi { + private ItemRepository itemRepository; + private CommentRepository commentRepository; + private CommentQueryService commentQueryService; + + @PostMapping + public ResponseEntity createComment( + @PathVariable("slug") String slug, + @AuthenticationPrincipal User user, + @Valid @RequestBody NewCommentParam newCommentParam) { + Item item = itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + Comment comment = new Comment(newCommentParam.getBody(), user.getId(), item.getId()); + commentRepository.save(comment); + return ResponseEntity.status(201) + .body(commentResponse(commentQueryService.findById(comment.getId(), user).get())); + } + + @GetMapping + public ResponseEntity getComments( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Item item = itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + List comments = commentQueryService.findByItemId(item.getId(), user); + return ResponseEntity.ok( + new HashMap() { + { + put("comments", comments); + } + }); + } + + @RequestMapping(path = "{id}", method = RequestMethod.DELETE) + public ResponseEntity deleteComment( + @PathVariable("slug") String slug, + @PathVariable("id") String commentId, + @AuthenticationPrincipal User user) { + Item item = itemRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + return commentRepository + .findById(item.getId(), commentId) + .map( + comment -> { + commentRepository.remove(comment); + return ResponseEntity.noContent().build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + private Map commentResponse(CommentData commentData) { + return new HashMap() { + { + put("comment", commentData); + } + }; + } +} + +@Getter +@NoArgsConstructor +@JsonRootName("comment") +class NewCommentParam { + @NotBlank(message = "can't be empty") + private String body; +} diff --git a/.framework/java/backend/src/main/java/io/spring/api/CurrentUserApi.java b/.framework/java/backend/src/main/java/io/spring/api/CurrentUserApi.java new file mode 100644 index 0000000..07f9dc9 --- /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 jakarta.validation.Valid; +import java.util.HashMap; +import java.util.Map; +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 = "/api/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 0000000..6384db4 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ItemApi.java @@ -0,0 +1,86 @@ +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.data.ItemData; +import io.spring.application.item.ItemCommandService; +import io.spring.application.item.UpdateItemParam; +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 jakarta.validation.Valid; +import java.util.HashMap; +import java.util.Map; +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 = "/api/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 0000000..48f7a47 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ItemFavoriteApi.java @@ -0,0 +1,59 @@ +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.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +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 = "/api/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 0000000..e5fb145 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/ItemsApi.java @@ -0,0 +1,67 @@ +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 io.spring.infrastructure.service.SendEventService; +import jakarta.validation.Valid; +import java.util.HashMap; +import java.util.Map; +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 = "/api/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); + + SendEventService sendEventService = new SendEventService(); + Map metadata = new HashMap<>(); + metadata.put("item", item.getTitle()); + sendEventService.sendEvent("item_created", metadata); + + 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/PingApi.java b/.framework/java/backend/src/main/java/io/spring/api/PingApi.java new file mode 100644 index 0000000..6883ac6 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/PingApi.java @@ -0,0 +1,21 @@ +package io.spring.api; + +import io.spring.infrastructure.service.SendEventService; +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 = "api/ping") +@AllArgsConstructor +public class PingApi { + @GetMapping + public ResponseEntity ping() { + SendEventService sendEventService = new SendEventService(); + String response = sendEventService.sendEvent("ping", new HashMap<>()); + return ResponseEntity.ok(response); + } +} 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 0000000..61429e1 --- /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 = "api/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 0000000..9720f37 --- /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 = "api/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 0000000..07f2771 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/UsersApi.java @@ -0,0 +1,86 @@ +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 io.spring.infrastructure.service.SendEventService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +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 = "/api/users", method = POST) + public ResponseEntity createUser(@Valid @RequestBody RegisterParam registerParam) { + User user = userService.createUser(registerParam); + + SendEventService sendEventService = new SendEventService(); + Map metadata = new HashMap<>(); + metadata.put("username", user.getUsername()); + sendEventService.sendEvent("user_created", metadata); + + UserData userData = userQueryService.findById(user.getId()).get(); + return ResponseEntity.status(201) + .body(userResponse(new UserWithToken(userData, jwtService.toToken(user)))); + } + + @RequestMapping(path = "/api/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 0000000..3e7c5c9 --- /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 jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +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, + HttpStatusCode 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 0000000..1c27805 --- /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 0000000..2ce3816 --- /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 0000000..13d5731 --- /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 0000000..96af7a8 --- /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 0000000..68b6c86 --- /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 0000000..6741423 --- /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 0000000..8401e52 --- /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 0000000..b03e9d1 --- /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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; +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 0000000..9523e96 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/api/security/WebSecurityConfig.java @@ -0,0 +1,80 @@ +package io.spring.api.security; + +import java.util.Arrays; +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.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +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 { + + @Bean + public JwtTokenFilter jwtTokenFilter() { + return new JwtTokenFilter(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .cors((cors) -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling( + exceptionHandling -> + exceptionHandling.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .authorizeHttpRequests( + auth -> + auth.requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + .requestMatchers("/actuator/**") + .permitAll() + .requestMatchers("/error") + .permitAll() + .requestMatchers("/health") + .permitAll() + .requestMatchers("/api/ping") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/items/feed") + .authenticated() + .requestMatchers(HttpMethod.POST, "/api/users", "/api/users/login") + .permitAll() + .requestMatchers( + HttpMethod.GET, "/api/items/**", "/api/profiles/**", "/api/tags") + .permitAll() + .anyRequest() + .authenticated()); + + http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("*")); + configuration.setAllowedHeaders(Arrays.asList("*")); + 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 0000000..dfba6ef --- /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 0000000..1953137 --- /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 0000000..13d55d4 --- /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 0000000..cfcc86b --- /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 0000000..d78a366 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/ItemQueryService.java @@ -0,0 +1,185 @@ +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); + } + + setFavoriteCount(Collections.singletonList(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); + int offset = page.getOffset(); + int limit = page.getLimit(); + int endIndex = Math.min(offset + limit, items.size()); + items = items.subList(offset, endIndex); + + 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 0000000..e4ccac8 --- /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 0000000..d273e99 --- /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 0000000..0279f3b --- /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 0000000..d92542d --- /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 0000000..12e0790 --- /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 0000000..f0f901a --- /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 0000000..a31dbf4 --- /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 0000000..e65f04c --- /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 0000000..983d17c --- /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 0000000..6d875df --- /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 0000000..82ef5f9 --- /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 0000000..c50cc19 --- /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 0000000..eac7f1b --- /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 0000000..31031a2 --- /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 jakarta.validation.Constraint; +import jakarta.validation.Payload; +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; + +@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 0000000..df7326b --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/DuplicatedItemValidator.java @@ -0,0 +1,17 @@ +package io.spring.application.item; + +import io.spring.application.ItemQueryService; +import io.spring.core.item.Item; +import jakarta.validation.ConstraintValidator; +import jakarta.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 0000000..420c93d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/ItemCommandService.java @@ -0,0 +1,36 @@ +package io.spring.application.item; + +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +import io.spring.core.user.User; +import jakarta.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 0000000..aa1ab9a --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/application/item/NewItemParam.java @@ -0,0 +1,27 @@ +package io.spring.application.item; + +import com.fasterxml.jackson.annotation.JsonRootName; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@JsonRootName("item") +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NewItemParam { + @NotBlank(message = "can't be empty") + @DuplicatedItemConstraint + private String title; + + @NotBlank(message = "can't be empty") + private String description; + + private String image; + + private List tagList; +} diff --git a/.framework/java/backend/src/main/java/io/spring/application/item/UpdateItemParam.java b/.framework/java/backend/src/main/java/io/spring/application/item/UpdateItemParam.java new file mode 100644 index 0000000..d2f214e --- /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 0000000..8e0f3d5 --- /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 jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@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 0000000..f754397 --- /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 jakarta.validation.ConstraintValidator; +import jakarta.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 0000000..0e54988 --- /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 jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@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 0000000..4b803bf --- /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 jakarta.validation.ConstraintValidator; +import jakarta.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 0000000..d629268 --- /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 jakarta.validation.constraints.Email; +import jakarta.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 0000000..9df5230 --- /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 0000000..f0263a0 --- /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 jakarta.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 0000000..7772b32 --- /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 jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Valid; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +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 0000000..ecfcfa1 --- /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 sellerId; + private String itemId; + private DateTime createdAt; + + public Comment(String body, String sellerId, String itemId) { + this.id = UUID.randomUUID().toString(); + this.body = body; + this.sellerId = sellerId; + 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 0000000..ad3ab07 --- /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 0000000..39ce24d --- /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 0000000..61db705 --- /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 0000000..6b39667 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/item/Item.java @@ -0,0 +1,74 @@ +package io.spring.core.item; + +import io.spring.Util; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +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 = + Optional.ofNullable(tagList) + .map(list -> new HashSet<>(list).stream().map(Tag::new).collect(Collectors.toList())) + .orElse(new ArrayList()); + + 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.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 0000000..6dea2ea --- /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 0000000..7edd313 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/core/item/Tag.java @@ -0,0 +1,22 @@ +package io.spring.core.item; + +import java.util.UUID; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.joda.time.DateTime; + +@NoArgsConstructor +@Data +@EqualsAndHashCode(of = "name") +public class Tag { + private String id; + private String name; + private DateTime createdAt; + + public Tag(String name) { + this.id = UUID.randomUUID().toString(); + this.name = name; + this.createdAt = new DateTime(); + } +} 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 0000000..080e742 --- /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.comment.Comment; +import io.spring.core.item.Item; +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.getSellerId()); + } +} 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 0000000..d143076 --- /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 0000000..7d7b538 --- /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 0000000..3044d50 --- /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 0000000..f52c772 --- /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/infrastructure/mybatis/DateTimeHandler.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/DateTimeHandler.java new file mode 100644 index 0000000..19323e5 --- /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 0000000..5b7c3cc --- /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 0000000..4ff92dc --- /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 0000000..8dd8696 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/mybatis/mapper/ItemMapper.java @@ -0,0 +1,29 @@ +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; +import org.joda.time.DateTime; + +@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, + @Param("createdAt") DateTime createdAt); + + 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 0000000..54f36c7 --- /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 0000000..2c27b7c --- /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 0000000..c913a23 --- /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 0000000..e8198f9 --- /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 0000000..8737687 --- /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 0000000..ae25a48 --- /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 0000000..4a8b230 --- /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 0000000..e0d33e3 --- /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 0000000..138bc99 --- /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 0000000..1f30b58 --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/repository/MyBatisItemRepository.java @@ -0,0 +1,59 @@ +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.joda.time.DateTime; +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(), new DateTime()); + } + 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 0000000..3c24dd5 --- /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 0000000..515d661 --- /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/java/io/spring/infrastructure/service/SendEventService.java b/.framework/java/backend/src/main/java/io/spring/infrastructure/service/SendEventService.java new file mode 100644 index 0000000..401704d --- /dev/null +++ b/.framework/java/backend/src/main/java/io/spring/infrastructure/service/SendEventService.java @@ -0,0 +1,77 @@ +package io.spring.infrastructure.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import okhttp3.*; + +public class SendEventService { + + private static final String PATH_TO_WILCO_ID = "../.wilco"; + private static final String BASE_URL = + Objects.requireNonNullElse(System.getenv("ENGINE_BASE_URL"), "https://engine.wilco.gg"); + private static String wilcoId; + + private final OkHttpClient client; + private final ObjectMapper objectMapper; + + public SendEventService() { + this.client = new OkHttpClient(); + this.objectMapper = new ObjectMapper(); + this.wilcoId = System.getenv("WILCO_ID"); + + if (wilcoId == null && doesFileExist(PATH_TO_WILCO_ID)) { + wilcoId = readFile(PATH_TO_WILCO_ID); + } + } + + public String sendEvent(String event, Map metadata) { + MediaType mediaType = MediaType.parse("application/json"); + + JsonNode metadataNode = objectMapper.valueToTree(metadata); + + ObjectNode data = objectMapper.createObjectNode(); + data.put("event", event); + data.set("metadata", metadataNode); + + RequestBody requestBody = RequestBody.create(mediaType, data.toString()); + + Request request = + new Request.Builder() + .url(BASE_URL + "/users/" + wilcoId + "/event") + .post(requestBody) + .addHeader("Content-type", "application/json") + .build(); + + try { + Response response = client.newCall(request).execute(); + return response.body().string(); + } catch (IOException e) { + e.printStackTrace(); + System.err.println("Failed to send event " + event + " to Wilco engine"); + return null; + } + } + + private static boolean doesFileExist(String filePath) { + return Objects.requireNonNull(new java.io.File(filePath).exists()); + } + + private static String readFile(String filePath) { + StringBuilder content = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) { + String line; + while ((line = reader.readLine()) != null) { + content.append(line); + } + } catch (IOException e) { + e.printStackTrace(); + } + return content.toString(); + } +} 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 0000000..612f0b6 --- /dev/null +++ b/.framework/java/backend/src/main/resources/application-test.properties @@ -0,0 +1,4 @@ +spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password 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 0000000..4319b5e --- /dev/null +++ b/.framework/java/backend/src/main/resources/application.properties @@ -0,0 +1,24 @@ +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 + +management.endpoints.web.base-path=/ 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 0000000..1a5b612 --- /dev/null +++ b/.framework/java/backend/src/main/resources/db/migration/V1__create_tables.sql @@ -0,0 +1,50 @@ +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, + created_at TIMESTAMP not null +); + +create table comments ( + id varchar(255) primary key, + body text, + item_id varchar(255), + seller_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 0000000..c715304 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/CommentMapper.xml @@ -0,0 +1,35 @@ + + + + + insert into comments(id, body, seller_id, item_id, created_at, updated_at) + values ( + #{comment.id}, + #{comment.body}, + #{comment.sellerId}, + #{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 0000000..cebb77e --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/CommentReadService.xml @@ -0,0 +1,44 @@ + + + + + SELECT + C.id commentId, + C.body commentBody, + C.created_at commentCreatedAt, + C.item_id commentItemId, + + from comments C + left join users U + on C.seller_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 0000000..5fbba91 --- /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 0000000..16a673f --- /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 0000000..f907a17 --- /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, created_at) values(#{itemId}, #{tagId}, #{createdAt}) + + + 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 0000000..2740017 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/ItemReadService.xml @@ -0,0 +1,160 @@ + + + + + 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 0000000..0e8ceef --- /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 0000000..45bcd8d --- /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 0000000..08e89b2 --- /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 0000000..edf53c1 --- /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 0000000..8bed754 --- /dev/null +++ b/.framework/java/backend/src/main/resources/mapper/UserRelationshipQueryService.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/.framework/java/backend/src/test/java/io/spring/AnythinkMarketApplicationTests.java b/.framework/java/backend/src/test/java/io/spring/AnythinkMarketApplicationTests.java new file mode 100644 index 0000000..cd81530 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/AnythinkMarketApplicationTests.java @@ -0,0 +1,13 @@ +package io.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest() +public class AnythinkMarketApplicationTests { + + @Test + public void contextLoads() {} +} diff --git a/.framework/java/backend/src/test/java/io/spring/TestHelper.java b/.framework/java/backend/src/test/java/io/spring/TestHelper.java new file mode 100644 index 0000000..3dd29f2 --- /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 0000000..c13246e --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/CommentsApiTest.java @@ -0,0 +1,141 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +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.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +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("/api/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("/api/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("/api/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("/api/items/{slug}/comments/{id}", item.getSlug(), comment.getId()) + .then() + .statusCode(204); + } +} 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 0000000..e00e05c --- /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("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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 0000000..c521582 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ItemApiTest.java @@ -0,0 +1,222 @@ +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.data.ItemData; +import io.spring.application.data.ProfileData; +import io.spring.application.item.ItemCommandService; +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", + "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("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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 0000000..5455a3c --- /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.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +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 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("/api/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("/api/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 0000000..bc02966 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ItemsApiTest.java @@ -0,0 +1,174 @@ +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.data.ItemData; +import io.spring.application.data.ProfileData; +import io.spring.application.item.ItemCommandService; +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("/api/items") + .then() + .statusCode(200) + .body("item.title", equalTo(title)) + .body("item.favorited", equalTo(false)) + .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 = ""; + String description = "Ever wonder how?"; + String image = "Image URL"; + 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("/api/items") + .prettyPeek() + .then() + .statusCode(422) + .body("errors.title[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, + image, + 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("/api/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 0000000..9f36d2c --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/api/ListItemApiTest.java @@ -0,0 +1,72 @@ +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.data.ItemDataList; +import io.spring.application.item.ItemCommandService; +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("/api/items").prettyPeek().then().statusCode(200); + } + + @Test + public void should_get_feeds_401_without_login() throws Exception { + RestAssuredMockMvc.when().get("/api/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("/api/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 0000000..2e3ab9c --- /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("/api/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("/api/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("/api/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 0000000..7d3b104 --- /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 0000000..2eb3ba6 --- /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("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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("/api/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 0000000..e36bc35 --- /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.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +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.MyBatisCommentRepository; +import io.spring.infrastructure.repository.MyBatisItemRepository; +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 0000000..7338ea6 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/application/item/ItemQueryServiceTest.java @@ -0,0 +1,226 @@ +package io.spring.application.item; + +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.ItemQueryService; +import io.spring.application.Page; +import io.spring.application.data.ItemData; +import io.spring.application.data.ItemDataList; +import io.spring.core.favorite.ItemFavorite; +import io.spring.core.favorite.ItemFavoriteRepository; +import io.spring.core.item.Item; +import io.spring.core.item.ItemRepository; +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 0000000..34ce502 --- /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 0000000..084cae8 --- /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", "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 0000000..e7cdd5c --- /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", 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", Arrays.asList("java"), "123"); + assertThat(item.getSlug(), is("a-new-title")); + } + + @Test + public void should_handle_other_language() { + Item item = new Item("中文:标题", "desc", "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", 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 0000000..80ed81c --- /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 0000000..8cc9a66 --- /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 0000000..d94e73f --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/favorite/MyBatisItemFavoriteRepositoryTest.java @@ -0,0 +1,33 @@ +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 0000000..63c441e --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/item/ItemRepositoryTransactionTest.java @@ -0,0 +1,40 @@ +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", Arrays.asList("java", "spring"), user.getId()); + itemRepository.save(item); + Item anotherItem = + new Item("test", "desc", "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 0000000..8bdc808 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/item/MyBatisItemRepositoryTest.java @@ -0,0 +1,72 @@ +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 java.util.Random; +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() { + Random random = new Random(); + int randomNumber = random.nextInt(); + User user = + new User( + "aisensiy" + randomNumber + "@gmail.com", "aisensiy" + randomNumber, "123", "", ""); + userRepository.save(user); + item = + new Item( + "test-" + randomNumber, "desc", "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); + } + + @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 0000000..b226170 --- /dev/null +++ b/.framework/java/backend/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java @@ -0,0 +1,42 @@ +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 0000000..8d4aaa2 --- /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@16322.com", "aisensiy999", "1234", "", ""); + } + + @Test + public void should_save_and_fetch_user_success() { + userRepository.save(user); + Optional userOptional = userRepository.findByUsername("aisensiy999"); + Assertions.assertEquals(userOptional.get(), user); + Optional userOptional2 = userRepository.findByEmail("aisensiy@16322.com"); + Assertions.assertEquals(userOptional2.get(), user); + } + + @Test + public void should_update_user_success() { + String newEmail = "newemail@email.com"; + user.update(newEmail, "aa", "bb", "cc", "dd"); + 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/backend/start.sh b/.framework/java/backend/start.sh new file mode 100755 index 0000000..b405727 --- /dev/null +++ b/.framework/java/backend/start.sh @@ -0,0 +1,6 @@ +./gradlew -i bootRun & + +while true; do + inotifywait -e modify,create,delete,move -r ./src/ && \ + ./gradlew -i assemble +done diff --git a/.framework/java/charts/.helmignore b/.framework/java/charts/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /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 0000000..b2beb19 --- /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 0000000..49515f2 --- /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 0000000..7b209f5 --- /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 0000000..21bb516 --- /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 0000000..f9be249 --- /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 0000000..217f8c5 --- /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 0000000..19752ee --- /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 0000000..88517f3 --- /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 0000000..80b47d3 --- /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 0000000..0464a04 --- /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 0000000..17f2aad --- /dev/null +++ b/.framework/java/docker-compose.yml @@ -0,0 +1,60 @@ +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 && ./start.sh" + + 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: + image: public.ecr.aws/v0a2l7y2/wilco/anythink-ack:latest + container_name: anythink-ack + environment: + - GITHUB_TOKEN=$GITHUB_TOKEN + - CODESPACE_NAME=$CODESPACE_NAME + depends_on: + - "anythink-frontend-react" diff --git a/.framework/node/.devcontainer/devcontainer.json b/.framework/node/.devcontainer/devcontainer.json new file mode 100644 index 0000000..171e2d2 --- /dev/null +++ b/.framework/node/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Anythink Development Container", + "image": "public.ecr.aws/v0a2l7y2/wilco/anythink-devcontainer:latest" +} diff --git a/.framework/node/backend/.gitignore b/.framework/node/backend/.gitignore new file mode 100644 index 0000000..a812403 --- /dev/null +++ b/.framework/node/backend/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +.DS_Store + +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +node_modules + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +.idea diff --git a/.framework/node/backend/Dockerfile b/.framework/node/backend/Dockerfile new file mode 100644 index 0000000..e779cce --- /dev/null +++ b/.framework/node/backend/Dockerfile @@ -0,0 +1 @@ +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-backend-node:latest diff --git a/.framework/node/backend/Dockerfile.aws b/.framework/node/backend/Dockerfile.aws new file mode 100644 index 0000000..7ce8e4a --- /dev/null +++ b/.framework/node/backend/Dockerfile.aws @@ -0,0 +1,10 @@ +FROM node:16 + +WORKDIR /usr/src +COPY backend ./backend +COPY .wilco ./.wilco + +# Pre-install npm packages +WORKDIR /usr/src/backend +RUN yarn install + diff --git a/.framework/node/backend/README.md b/.framework/node/backend/README.md new file mode 100644 index 0000000..14f890a --- /dev/null +++ b/.framework/node/backend/README.md @@ -0,0 +1,22 @@ +# Anythink Market Backend + +The Anythink Market backend is Node web app written with [Express](https://expressjs.com/) + +## Dependencies + +- [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) - For generating JWTs used by authentication +- [mongoose](https://github.com/Automattic/mongoose) - For modeling and mapping MongoDB data to javascript +- [mongoose-unique-validator](https://github.com/blakehaswell/mongoose-unique-validator) - For handling unique validation errors in Mongoose. Mongoose only handles validation at the document level, so a unique index across a collection will throw an exception at the driver level. The `mongoose-unique-validator` plugin helps us by formatting the error like a normal mongoose `ValidationError`. +- [passport](https://github.com/jaredhanson/passport) - For handling user authentication +- [slug](https://github.com/dodo/node-slug) - For encoding titles into a URL-friendly format + +## Application Structure + +- `app.js` - The entry point to our application. This file defines our express server and connects it to MongoDB using mongoose. It also requires the routes and models we'll be using in the application. +- `config/` - This folder contains configuration for passport as well as a central location for configuration/environment variables. +- `routes/` - This folder contains the route definitions for our API. +- `models/` - This folder contains the schema definitions for our Mongoose models. + +## Error Handling + +In `routes/api/index.js`, we define a error-handling middleware for handling Mongoose's `ValidationError`. This middleware will respond with a 422 status code and format the response to have [error messages the clients can understand](https://github.com/gothinkster/realworld/blob/master/API.md#errors-and-status-codes) diff --git a/.framework/node/backend/app.js b/.framework/node/backend/app.js new file mode 100644 index 0000000..f4e7f35 --- /dev/null +++ b/.framework/node/backend/app.js @@ -0,0 +1,89 @@ +require("dotenv").config(); +var http = require("http"), + path = require("path"), + methods = require("methods"), + express = require("express"), + bodyParser = require("body-parser"), + session = require("express-session"), + cors = require("cors"), + passport = require("passport"), + errorhandler = require("errorhandler"), + mongoose = require("mongoose"); + +var isProduction = process.env.NODE_ENV === "production"; + +// Create global app object +var app = express(); + +app.use(cors()); + +// Normal express config defaults +app.use(require("morgan")("dev")); +app.use(bodyParser.urlencoded({ extended: false })); +app.use(bodyParser.json()); + +app.use(require("method-override")()); +app.use(express.static(__dirname + "/public")); + +app.use( + session({ + secret: "secret", + cookie: { maxAge: 60000 }, + resave: false, + saveUninitialized: false + }) +); + +if (!isProduction) { + app.use(errorhandler()); +} + +if (!process.env.MONGODB_URI) { + console.warn("Missing MONGODB_URI in env, please add it to your .env file"); +} + +mongoose.connect(process.env.MONGODB_URI); +if (isProduction) { +} else { + mongoose.set("debug", true); +} + +require("./models/User"); +require("./models/Item"); +require("./models/Comment"); +require("./config/passport"); + +app.use(require("./routes")); + +/// catch 404 and forward to error handler +app.use(function (req, res, next) { + if (req.url === "/favicon.ico") { + res.writeHead(200, { "Content-Type": "image/x-icon" }); + res.end(); + } else { + const err = new Error("Not Found"); + err.status = 404; + next(err); + } +}); + +/// error handler +app.use(function(err, req, res, next) { + console.log(err.stack); + if (isProduction) { + res.sendStatus(err.status || 500) + } else { + res.status(err.status || 500); + res.json({ + errors: { + message: err.message, + error: err + } + }); + } +}); + +// finally, let's start our server... +var server = app.listen(process.env.PORT || 3000, function() { + console.log("Listening on port " + server.address().port); +}); diff --git a/.framework/node/backend/config/index.js b/.framework/node/backend/config/index.js new file mode 100644 index 0000000..1bf9d6a --- /dev/null +++ b/.framework/node/backend/config/index.js @@ -0,0 +1,3 @@ +module.exports = { + secret: process.env.NODE_ENV === 'production' ? process.env.SECRET : 'secret' +}; diff --git a/.framework/node/backend/config/passport.js b/.framework/node/backend/config/passport.js new file mode 100644 index 0000000..abe0ce2 --- /dev/null +++ b/.framework/node/backend/config/passport.js @@ -0,0 +1,18 @@ +var passport = require('passport'); +var LocalStrategy = require('passport-local').Strategy; +var mongoose = require('mongoose'); +var User = mongoose.model('User'); + +passport.use(new LocalStrategy({ + usernameField: 'user[email]', + passwordField: 'user[password]' +}, function(email, password, done) { + User.findOne({email: email}).then(function(user){ + if(!user || !user.validPassword(password)){ + return done(null, false, {errors: {'email or password': 'is invalid'}}); + } + + return done(null, user); + }).catch(done); +})); + diff --git a/.framework/node/backend/lib/event.js b/.framework/node/backend/lib/event.js new file mode 100644 index 0000000..48a270e --- /dev/null +++ b/.framework/node/backend/lib/event.js @@ -0,0 +1,25 @@ +const axiosLib = require("axios"); +const fs = require("fs"); + +const WILCO_ID = process.env.WILCO_ID || fs.readFileSync('../.wilco', 'utf8') +const baseURL = process.env.ENGINE_BASE_URL || "https://engine.wilco.gg" + +const axios = axiosLib.create({ + baseURL: baseURL, + headers: { + 'Content-type': 'application/json', + }, +}); + +async function sendEvent(event, metadata) { + try { + const result = await axios.post(`/users/${WILCO_ID}/event`, JSON.stringify({event, metadata})); + return result.data; + } catch (error) { + console.error(`failed to send event ${event} to Wilco engine`) + } +} + +module.exports = { + sendEvent, +} diff --git a/.framework/node/backend/models/Comment.js b/.framework/node/backend/models/Comment.js new file mode 100644 index 0000000..995c6c0 --- /dev/null +++ b/.framework/node/backend/models/Comment.js @@ -0,0 +1,22 @@ +var mongoose = require("mongoose"); + +var CommentSchema = new mongoose.Schema( + { + body: String, + seller: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, + item: { type: mongoose.Schema.Types.ObjectId, ref: "Item" } + }, + { timestamps: true } +); + +// Requires population of seller +CommentSchema.methods.toJSONFor = function(user) { + return { + id: this._id, + body: this.body, + createdAt: this.createdAt, + seller: this.seller.toProfileJSONFor(user) + }; +}; + +mongoose.model("Comment", CommentSchema); diff --git a/.framework/node/backend/models/Item.js b/.framework/node/backend/models/Item.js new file mode 100644 index 0000000..96421a3 --- /dev/null +++ b/.framework/node/backend/models/Item.js @@ -0,0 +1,62 @@ +var mongoose = require("mongoose"); +var uniqueValidator = require("mongoose-unique-validator"); +var slug = require("slug"); +var User = mongoose.model("User"); + +var ItemSchema = new mongoose.Schema( + { + slug: { type: String, lowercase: true, unique: true }, + title: {type: String, required: [true, "can't be blank"]}, + description: {type: String, required: [true, "can't be blank"]}, + image: String, + favoritesCount: { type: Number, default: 0 }, + comments: [{ type: mongoose.Schema.Types.ObjectId, ref: "Comment" }], + tagList: [{ type: String }], + seller: { type: mongoose.Schema.Types.ObjectId, ref: "User" } + }, + { timestamps: true } +); + +ItemSchema.plugin(uniqueValidator, { message: "is already taken" }); + +ItemSchema.pre("validate", function(next) { + if (!this.slug) { + this.slugify(); + } + + next(); +}); + +ItemSchema.methods.slugify = function() { + this.slug = + slug(this.title) + + "-" + + ((Math.random() * Math.pow(36, 6)) | 0).toString(36); +}; + +ItemSchema.methods.updateFavoriteCount = function() { + var item = this; + + return User.count({ favorites: { $in: [item._id] } }).then(function(count) { + item.favoritesCount = count; + + return item.save(); + }); +}; + +ItemSchema.methods.toJSONFor = function(user) { + return { + slug: this.slug, + title: this.title, + description: this.description, + image: this.image, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + tagList: this.tagList, + favorited: user ? user.isFavorite(this._id) : false, + favoritesCount: this.favoritesCount, + seller: this.seller.toProfileJSONFor(user) + }; +}; + +mongoose.model("Item", ItemSchema); diff --git a/.framework/node/backend/models/User.js b/.framework/node/backend/models/User.js new file mode 100644 index 0000000..8616f03 --- /dev/null +++ b/.framework/node/backend/models/User.js @@ -0,0 +1,130 @@ +var mongoose = require("mongoose"); +var uniqueValidator = require("mongoose-unique-validator"); +var crypto = require("crypto"); +var jwt = require("jsonwebtoken"); +var secret = require("../config").secret; + +var UserSchema = new mongoose.Schema( + { + username: { + type: String, + lowercase: true, + unique: true, + required: [true, "can't be blank"], + match: [/^[a-zA-Z0-9]+$/, "is invalid"], + index: true + }, + email: { + type: String, + lowercase: true, + unique: true, + required: [true, "can't be blank"], + match: [/\S+@\S+\.\S+/, "is invalid"], + index: true + }, + bio: String, + image: String, + role: { + type: String, + enum: ["user", "admin"], + default: "user" + }, + favorites: [{ type: mongoose.Schema.Types.ObjectId, ref: "Item" }], + following: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + hash: String, + salt: String + }, + { timestamps: true } +); + +UserSchema.plugin(uniqueValidator, { message: "is already taken." }); + +UserSchema.methods.validPassword = function(password) { + var hash = crypto + .pbkdf2Sync(password, this.salt, 10000, 512, "sha512") + .toString("hex"); + return this.hash === hash; +}; + +UserSchema.methods.setPassword = function(password) { + this.salt = crypto.randomBytes(16).toString("hex"); + this.hash = crypto + .pbkdf2Sync(password, this.salt, 10000, 512, "sha512") + .toString("hex"); +}; + +UserSchema.methods.generateJWT = function() { + var today = new Date(); + var exp = new Date(today); + exp.setDate(today.getDate() + 60); + + return jwt.sign( + { + id: this._id, + username: this.username, + exp: parseInt(exp.getTime() / 1000) + }, + secret + ); +}; + +UserSchema.methods.toAuthJSON = function() { + return { + username: this.username, + email: this.email, + token: this.generateJWT(), + bio: this.bio, + image: this.image, + role: this.role + }; +}; + +UserSchema.methods.toProfileJSONFor = function(user) { + return { + username: this.username, + bio: this.bio, + image: + this.image || "https://static.productionready.io/images/smiley-cyrus.jpg", + following: user ? user.isFollowing(this._id) : false + }; +}; + +UserSchema.methods.favorite = function(id) { + if (this.favorites.indexOf(id) === -1) { + this.favorites = this.favorites.concat([id]); + } + + return this.save(); +}; + +UserSchema.methods.unfavorite = function(id) { + this.favorites.remove(id); + return this.save(); +}; + +UserSchema.methods.isFavorite = function(id) { + return this.favorites.some(function(favoriteId) { + return favoriteId.toString() === id.toString(); + }); +}; + +UserSchema.methods.follow = function(id) { + if (this.following.indexOf(id) === -1) { + this.following = this.following.concat([id]); + } + + return this.save(); +}; + +UserSchema.methods.unfollow = function(id) { + this.following.remove(id); + return this.save(); +}; + +UserSchema.methods.isFollowing = function(id) { + return this.following.some(function(followId) { + return followId.toString() === id.toString(); + }); +}; + +mongoose.model("User", UserSchema); diff --git a/.framework/node/backend/package.json b/.framework/node/backend/package.json new file mode 100644 index 0000000..4dcd6eb --- /dev/null +++ b/.framework/node/backend/package.json @@ -0,0 +1,42 @@ +{ + "name": "anythink-market-backend", + "version": "1.0.0", + "main": "app.js", + "engines": { + "node": "^16" + }, + "scripts": { + "start": "node ./app.js", + "dev": "nodemon ./app.js", + "seeds": "node ./scripts/seeds.js", + "test": "newman run ./tests/api-tests.postman.json -e ./tests/env-api-tests.postman.json", + "stop": "lsof -ti :3000 | xargs kill" + }, + "dependencies": { + "axios": "^0.25.0", + "body-parser": "1.15.0", + "cors": "2.7.1", + "dotenv": "^8.2.0", + "ejs": "2.4.1", + "errorhandler": "1.4.3", + "express": "4.13.4", + "express-async-handler": "^1.2.0", + "express-jwt": "3.3.0", + "express-session": "1.13.0", + "jsonwebtoken": "7.1.9", + "method-override": "2.3.5", + "methods": "1.1.2", + "mongoose": "5.12.5", + "mongoose-unique-validator": "^3.0.0", + "morgan": "1.7.0", + "passport": "0.3.2", + "passport-local": "1.0.0", + "request": "2.69.0", + "slug": "0.9.1", + "underscore": "1.8.3" + }, + "devDependencies": { + "newman": "^3.8.2", + "nodemon": "^1.11.0" + } +} diff --git a/.framework/node/backend/public/.keep b/.framework/node/backend/public/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/node/backend/routes/api/index.js b/.framework/node/backend/routes/api/index.js new file mode 100644 index 0000000..380d027 --- /dev/null +++ b/.framework/node/backend/routes/api/index.js @@ -0,0 +1,23 @@ +var router = require('express').Router(); + +router.use('/', require('./users')); +router.use('/profiles', require('./profiles')); +router.use('/items', require('./items')); +router.use('/tags', require('./tags')); +router.use('/ping', require('./ping')); + +router.use(function(err, req, res, next){ + if(err.name === 'ValidationError'){ + return res.status(422).json({ + errors: Object.keys(err.errors).reduce(function(errors, key){ + errors[key] = err.errors[key].message; + + return errors; + }, {}) + }); + } + + return next(err); +}); + +module.exports = router; \ No newline at end of file diff --git a/.framework/node/backend/routes/api/items.js b/.framework/node/backend/routes/api/items.js new file mode 100644 index 0000000..84a8af9 --- /dev/null +++ b/.framework/node/backend/routes/api/items.js @@ -0,0 +1,331 @@ +var router = require("express").Router(); +var mongoose = require("mongoose"); +var Item = mongoose.model("Item"); +var Comment = mongoose.model("Comment"); +var User = mongoose.model("User"); +var auth = require("../auth"); +const { sendEvent } = require("../../lib/event"); + +// Preload item objects on routes with ':item' +router.param("item", function(req, res, next, slug) { + Item.findOne({ slug: slug }) + .populate("seller") + .then(function(item) { + if (!item) { + return res.sendStatus(404); + } + + req.item = item; + + return next(); + }) + .catch(next); +}); + +router.param("comment", function(req, res, next, id) { + Comment.findById(id) + .then(function(comment) { + if (!comment) { + return res.sendStatus(404); + } + + req.comment = comment; + + return next(); + }) + .catch(next); +}); + +router.get("/", auth.optional, function(req, res, next) { + var query = {}; + var limit = 100; + var offset = 0; + + if (typeof req.query.limit !== "undefined") { + limit = req.query.limit; + } + + if (typeof req.query.offset !== "undefined") { + offset = req.query.offset; + } + + if (typeof req.query.tag !== "undefined") { + query.tagList = { $in: [req.query.tag] }; + } + + Promise.all([ + req.query.seller ? User.findOne({ username: req.query.seller }) : null, + req.query.favorited ? User.findOne({ username: req.query.favorited }) : null + ]) + .then(function(results) { + var seller = results[0]; + var favoriter = results[1]; + + if (seller) { + query.seller = seller._id; + } + + if (favoriter) { + query._id = { $in: favoriter.favorites }; + } else if (req.query.favorited) { + query._id = { $in: [] }; + } + + return Promise.all([ + Item.find(query) + .limit(Number(limit)) + .skip(Number(offset)) + .sort({ createdAt: "desc" }) + .exec(), + Item.count(query).exec(), + req.payload ? User.findById(req.payload.id) : null + ]).then(async function(results) { + var items = results[0]; + var itemsCount = results[1]; + var user = results[2]; + return res.json({ + items: await Promise.all( + items.map(async function(item) { + item.seller = await User.findById(item.seller); + return item.toJSONFor(user); + }) + ), + itemsCount: itemsCount + }); + }); + }) + .catch(next); +}); + +router.get("/feed", auth.required, function(req, res, next) { + var limit = 20; + var offset = 0; + + if (typeof req.query.limit !== "undefined") { + limit = req.query.limit; + } + + if (typeof req.query.offset !== "undefined") { + offset = req.query.offset; + } + + User.findById(req.payload.id).then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + Promise.all([ + Item.find({ seller: { $in: user.following } }) + .limit(Number(limit)) + .skip(Number(offset)) + .populate("seller") + .exec(), + Item.count({ seller: { $in: user.following } }) + ]) + .then(function(results) { + var items = results[0]; + var itemsCount = results[1]; + + return res.json({ + items: items.map(function(item) { + return item.toJSONFor(user); + }), + itemsCount: itemsCount + }); + }) + .catch(next); + }); +}); + +router.post("/", auth.required, function(req, res, next) { + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + var item = new Item(req.body.item); + + item.seller = user; + + return item.save().then(function() { + sendEvent('item_created', { item: req.body.item }) + return res.json({ item: item.toJSONFor(user) }); + }); + }) + .catch(next); +}); + +// return a item +router.get("/:item", auth.optional, function(req, res, next) { + Promise.all([ + req.payload ? User.findById(req.payload.id) : null, + req.item.populate("seller").execPopulate() + ]) + .then(function(results) { + var user = results[0]; + + return res.json({ item: req.item.toJSONFor(user) }); + }) + .catch(next); +}); + +// update item +router.put("/:item", auth.required, function(req, res, next) { + User.findById(req.payload.id).then(function(user) { + if (req.item.seller._id.toString() === req.payload.id.toString()) { + if (typeof req.body.item.title !== "undefined") { + req.item.title = req.body.item.title; + } + + if (typeof req.body.item.description !== "undefined") { + req.item.description = req.body.item.description; + } + + if (typeof req.body.item.image !== "undefined") { + req.item.image = req.body.item.image; + } + + if (typeof req.body.item.tagList !== "undefined") { + req.item.tagList = req.body.item.tagList; + } + + req.item + .save() + .then(function(item) { + return res.json({ item: item.toJSONFor(user) }); + }) + .catch(next); + } else { + return res.sendStatus(403); + } + }); +}); + +// delete item +router.delete("/:item", auth.required, function(req, res, next) { + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + if (req.item.seller._id.toString() === req.payload.id.toString()) { + return req.item.remove().then(function() { + return res.sendStatus(204); + }); + } else { + return res.sendStatus(403); + } + }) + .catch(next); +}); + +// Favorite an item +router.post("/:item/favorite", auth.required, function(req, res, next) { + var itemId = req.item._id; + + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + return user.favorite(itemId).then(function() { + return req.item.updateFavoriteCount().then(function(item) { + return res.json({ item: item.toJSONFor(user) }); + }); + }); + }) + .catch(next); +}); + +// Unfavorite an item +router.delete("/:item/favorite", auth.required, function(req, res, next) { + var itemId = req.item._id; + + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + return user.unfavorite(itemId).then(function() { + return req.item.updateFavoriteCount().then(function(item) { + return res.json({ item: item.toJSONFor(user) }); + }); + }); + }) + .catch(next); +}); + +// return an item's comments +router.get("/:item/comments", auth.optional, function(req, res, next) { + Promise.resolve(req.payload ? User.findById(req.payload.id) : null) + .then(function(user) { + return req.item + .populate({ + path: "comments", + populate: { + path: "seller" + }, + options: { + sort: { + createdAt: "desc" + } + } + }) + .execPopulate() + .then(function(item) { + return res.json({ + comments: req.item.comments.map(function(comment) { + return comment.toJSONFor(user); + }) + }); + }); + }) + .catch(next); +}); + +// create a new comment +router.post("/:item/comments", auth.required, function(req, res, next) { + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + var comment = new Comment(req.body.comment); + comment.item = req.item; + comment.seller = user; + + return comment.save().then(function() { + req.item.comments = req.item.comments.concat([comment]); + + return req.item.save().then(function(item) { + res.json({ comment: comment.toJSONFor(user) }); + }); + }); + }) + .catch(next); +}); + +router.delete("/:item/comments/:comment", auth.required, function( + req, + res, + next +) { + req.item.comments.remove(req.comment._id); + req.item + .save() + .then( + Comment.find({ _id: req.comment._id }) + .remove() + .exec() + ) + .then(function() { + res.sendStatus(204); + }); +}); + +module.exports = router; diff --git a/.framework/node/backend/routes/api/ping.js b/.framework/node/backend/routes/api/ping.js new file mode 100644 index 0000000..a327948 --- /dev/null +++ b/.framework/node/backend/routes/api/ping.js @@ -0,0 +1,19 @@ +const router = require("express").Router(); +const asyncHandler = require("express-async-handler"); +const auth = require("../auth"); +const { sendEvent } = require("../../lib/event"); + +router.get("/", + auth.optional, + asyncHandler(async (req, res) => { + + try { + const result = await sendEvent('ping') + return res.json(result); + } catch (e) { + console.error(e) + return res.sendStatus(500); + } + })); + +module.exports = router; diff --git a/.framework/node/backend/routes/api/profiles.js b/.framework/node/backend/routes/api/profiles.js new file mode 100644 index 0000000..ffcd833 --- /dev/null +++ b/.framework/node/backend/routes/api/profiles.js @@ -0,0 +1,53 @@ +var router = require('express').Router(); +var mongoose = require('mongoose'); +var User = mongoose.model('User'); +var auth = require('../auth'); + +// Preload user profile on routes with ':username' +router.param('username', function(req, res, next, username){ + User.findOne({username: username}).then(function(user){ + if (!user) { return res.sendStatus(404); } + + req.profile = user; + + return next(); + }).catch(next); +}); + +router.get('/:username', auth.optional, function(req, res, next){ + if(req.payload){ + User.findById(req.payload.id).then(function(user){ + if(!user){ return res.json({profile: req.profile.toProfileJSONFor(false)}); } + + return res.json({profile: req.profile.toProfileJSONFor(user)}); + }); + } else { + return res.json({profile: req.profile.toProfileJSONFor(false)}); + } +}); + +router.post('/:username/follow', auth.required, function(req, res, next){ + var profileId = req.profile._id; + + User.findById(req.payload.id).then(function(user){ + if (!user) { return res.sendStatus(401); } + + return user.follow(profileId).then(function(){ + return res.json({profile: req.profile.toProfileJSONFor(user)}); + }); + }).catch(next); +}); + +router.delete('/:username/follow', auth.required, function(req, res, next){ + var profileId = req.profile._id; + + User.findById(req.payload.id).then(function(user){ + if (!user) { return res.sendStatus(401); } + + return user.unfollow(profileId).then(function(){ + return res.json({profile: req.profile.toProfileJSONFor(user)}); + }); + }).catch(next); +}); + +module.exports = router; diff --git a/.framework/node/backend/routes/api/tags.js b/.framework/node/backend/routes/api/tags.js new file mode 100644 index 0000000..2090495 --- /dev/null +++ b/.framework/node/backend/routes/api/tags.js @@ -0,0 +1,12 @@ +var router = require('express').Router(); +var mongoose = require('mongoose'); +var Item = mongoose.model('Item'); + +// return a list of tags +router.get('/', function(req, res, next) { + Item.find().distinct('tagList').then(function(tags){ + return res.json({tags: tags}); + }).catch(next); +}); + +module.exports = router; diff --git a/.framework/node/backend/routes/api/users.js b/.framework/node/backend/routes/api/users.js new file mode 100644 index 0000000..aeae77f --- /dev/null +++ b/.framework/node/backend/routes/api/users.js @@ -0,0 +1,90 @@ +var mongoose = require("mongoose"); +var router = require("express").Router(); +var passport = require("passport"); +var User = mongoose.model("User"); +var auth = require("../auth"); +const { sendEvent } = require("../../lib/event"); + +router.get("/user", auth.required, function(req, res, next) { + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + return res.json({ user: user.toAuthJSON() }); + }) + .catch(next); +}); + +router.put("/user", auth.required, function(req, res, next) { + User.findById(req.payload.id) + .then(function(user) { + if (!user) { + return res.sendStatus(401); + } + + // only update fields that were actually passed... + if (typeof req.body.user.username !== "undefined") { + user.username = req.body.user.username; + } + if (typeof req.body.user.email !== "undefined") { + user.email = req.body.user.email; + } + if (typeof req.body.user.bio !== "undefined") { + user.bio = req.body.user.bio; + } + if (typeof req.body.user.image !== "undefined") { + user.image = req.body.user.image; + } + if (typeof req.body.user.password !== "undefined") { + user.setPassword(req.body.user.password); + } + + return user.save().then(function() { + return res.json({ user: user.toAuthJSON() }); + }); + }) + .catch(next); +}); + +router.post("/users/login", function(req, res, next) { + if (!req.body.user.email) { + return res.status(422).json({ errors: { email: "can't be blank" } }); + } + + if (!req.body.user.password) { + return res.status(422).json({ errors: { password: "can't be blank" } }); + } + + passport.authenticate("local", { session: false }, function(err, user, info) { + if (err) { + return next(err); + } + + if (user) { + user.token = user.generateJWT(); + return res.json({ user: user.toAuthJSON() }); + } else { + return res.status(422).json(info); + } + })(req, res, next); +}); + +router.post("/users", function(req, res, next) { + var user = new User(); + + user.username = req.body.user.username; + user.email = req.body.user.email; + user.setPassword(req.body.user.password); + + user + .save() + .then(function() { + sendEvent('user_created', { username: req.body.user.username }) + return res.json({ user: user.toAuthJSON() }); + }) + .catch(next); +}); + +module.exports = router; diff --git a/.framework/node/backend/routes/auth.js b/.framework/node/backend/routes/auth.js new file mode 100644 index 0000000..e44a215 --- /dev/null +++ b/.framework/node/backend/routes/auth.js @@ -0,0 +1,27 @@ +var jwt = require('express-jwt'); +var secret = require('../config').secret; + +function getTokenFromHeader(req){ + if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Token' || + req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { + return req.headers.authorization.split(' ')[1]; + } + + return null; +} + +var auth = { + required: jwt({ + secret: secret, + userProperty: 'payload', + getToken: getTokenFromHeader + }), + optional: jwt({ + secret: secret, + userProperty: 'payload', + credentialsRequired: false, + getToken: getTokenFromHeader + }) +}; + +module.exports = auth; diff --git a/.framework/node/backend/routes/index.js b/.framework/node/backend/routes/index.js new file mode 100644 index 0000000..81d38f9 --- /dev/null +++ b/.framework/node/backend/routes/index.js @@ -0,0 +1,13 @@ +var router = require('express').Router(); + +router.get('/', (req, res, next) => { + res.send("Anythink backend is up."); +}); + +router.get('/health', (req, res, next) => { + res.sendStatus("200"); +}) + +router.use('/api', require('./api')); + +module.exports = router; diff --git a/.framework/node/backend/scripts/seeds.js b/.framework/node/backend/scripts/seeds.js new file mode 100644 index 0000000..4989da1 --- /dev/null +++ b/.framework/node/backend/scripts/seeds.js @@ -0,0 +1 @@ +//TODO: seeds script should come here, so we'll be able to put some data in our local env diff --git a/.framework/node/backend/seeds.sh b/.framework/node/backend/seeds.sh new file mode 100755 index 0000000..855f73d --- /dev/null +++ b/.framework/node/backend/seeds.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +yarn seeds diff --git a/.framework/node/backend/start.sh b/.framework/node/backend/start.sh new file mode 100755 index 0000000..bca7355 --- /dev/null +++ b/.framework/node/backend/start.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +yarn start diff --git a/.framework/node/backend/tests/api-tests.postman.json b/.framework/node/backend/tests/api-tests.postman.json new file mode 100644 index 0000000..fcfaf1a --- /dev/null +++ b/.framework/node/backend/tests/api-tests.postman.json @@ -0,0 +1,1900 @@ +{ + "variables": [], + "info": { + "name": "Anythink-Market API Tests", + "_postman_id": "dda3e595-02d7-bf12-2a43-3daea0970192", + "description": "Collection for testing the Anythink-Market API\n\nhttps://github.com/gothinkster/realworld", + "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" + }, + "item": [{ + "name": "Auth", + "description": "", + "item": [{ + "name": "Register", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (!(environment.isIntegrationTest)) {", + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/users", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"john@jacob.com\", \"password\":\"johnnyjacob\", \"username\":\"johnjacob\"}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Login", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/users/login", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"john@jacob.com\", \"password\":\"johnnyjacob\"}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Login and Remember Token", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "", + "if(tests['User has \"token\" property']){", + " postman.setEnvironmentVariable('token', user.token);", + "}", + "", + "tests['Environment variable \"token\" has been set'] = environment.token === user.token;", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/users/login", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"john@jacob.com\", \"password\":\"johnnyjacob\"}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Current User", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/user", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + }, + { + "name": "Update User", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", + "", + "var user = responseJSON.user || {};", + "", + "tests['User has \"email\" property'] = user.hasOwnProperty('email');", + "tests['User has \"username\" property'] = user.hasOwnProperty('username');", + "tests['User has \"token\" property'] = user.hasOwnProperty('token');", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/user", + "method": "PUT", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"john@jacob.com\"}}" + }, + "description": "" + }, + "response": [] + } + ] + }, + { + "name": "Items with authentication", + "description": "", + "item": [{ + "name": "Feed", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/feed", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "All Items", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "All Items with auth", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items by Author", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?seller=johnjacob", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "seller", + "value": "johnjacob" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items by Author with auth", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?seller=johnjacob", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "seller", + "value": "johnjacob", + "equals": true, + "description": "" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + }, + { + "name": "Items Favorited by Username", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " ", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?favorited=jane", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "favorited", + "value": "jane" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items Favorited by Username with auth", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " ", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?favorited=jane", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "favorited", + "value": "jane" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items by Tag", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?tag=dragons", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "tag", + "value": "dragons" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Create Item", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"item\" property'] = responseJSON.hasOwnProperty('item');", + "", + "var item = responseJSON.item || {};", + "", + "tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + "tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + "if(tests['Item has \"slug\" property']){", + " postman.setEnvironmentVariable('slug', item.slug);", + "}", + "tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + "tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + "tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + "tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + "tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + "tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + "tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + "tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + "tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + "tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + "tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"item\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"dragons\",\"training\"]}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Single Item by slug", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"item\" property'] = responseJSON.hasOwnProperty('item');", + "", + "var item = responseJSON.item || {};", + "", + "tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + "tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + "tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + "tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + "tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + "tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + "tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + "tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + "tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + "tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + "tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + "tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + "tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + }, + { + "name": "Update Item", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (!(environment.isIntegrationTest)) {", + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"item\" property'] = responseJSON.hasOwnProperty('item');", + "", + "var item = responseJSON.item || {};", + "", + "tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + "tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + "tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + "tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + "tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + "tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + "tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + "tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + "tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + "tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + "tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + "tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + "tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}", + "method": "PUT", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"item\":{\"body\":\"With two hands\"}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Favorite Item", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"item\" property'] = responseJSON.hasOwnProperty('item');", + "", + "var item = responseJSON.item || {};", + "", + "tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + "tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + "tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + "tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + "tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + "tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + "tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + "tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + "tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + "tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + "tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + "tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + "tests[\"Item's 'favorited' property is true\"] = item.favorited === true;", + "tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + "tests[\"Item's 'favoritesCount' property is greater than 0\"] = item.favoritesCount > 0;", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}/favorite", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Unfavorite Item", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"item\" property'] = responseJSON.hasOwnProperty('item');", + "", + "var item = responseJSON.item || {};", + "", + "tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + "tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + "tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + "tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + "tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + "tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + "tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + "tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + "tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + "tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + "tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + "tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + "tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + "tests[\"Item's \\\"favorited\\\" property is true\"] = item.favorited === false;", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}/favorite", + "method": "DELETE", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + } + ] + }, + { + "name": "Items", + "description": "", + "item": [{ + "name": "All Items", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items by Author", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?seller=johnjacob", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "seller", + "value": "johnjacob" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items Favorited by Username", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " ", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?favorited=jane", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "favorited", + "value": "jane" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Items by Tag", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"items\" property'] = responseJSON.hasOwnProperty('items');", + " tests['Response contains \"itemsCount\" property'] = responseJSON.hasOwnProperty('itemsCount');", + " tests['itemsCount is an integer'] = Number.isInteger(responseJSON.itemsCount);", + "", + " if(responseJSON.items.length){", + " var item = responseJSON.items[0];", + "", + " tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + " tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + " tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + " tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + " tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + " tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + " tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + " tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + " tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + " tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + " tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + " tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + " tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + " tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + " } else {", + " tests['itemsCount is 0 when feed is empty'] = responseJSON.itemsCount === 0;", + " }", + "}", + "" + ] + } + }], + "request": { + "url": { + "raw": "{{apiUrl}}/items?tag=dragons", + "host": [ + "{{apiUrl}}" + ], + "path": [ + "items" + ], + "query": [{ + "key": "tag", + "value": "dragons" + }], + "variable": [] + }, + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Single Item by slug", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"item\" property'] = responseJSON.hasOwnProperty('item');", + "", + "var item = responseJSON.item || {};", + "", + "tests['Item has \"title\" property'] = item.hasOwnProperty('title');", + "tests['Item has \"slug\" property'] = item.hasOwnProperty('slug');", + "tests['Item has \"body\" property'] = item.hasOwnProperty('body');", + "tests['Item has \"createdAt\" property'] = item.hasOwnProperty('createdAt');", + "tests['Item\\'s \"createdAt\" property is an ISO 8601 timestamp'] = new Date(item.createdAt).toISOString() === item.createdAt;", + "tests['Item has \"updatedAt\" property'] = item.hasOwnProperty('updatedAt');", + "tests['Item\\'s \"updatedAt\" property is an ISO 8601 timestamp'] = new Date(item.updatedAt).toISOString() === item.updatedAt;", + "tests['Item has \"description\" property'] = item.hasOwnProperty('description');", + "tests['Item has \"tagList\" property'] = item.hasOwnProperty('tagList');", + "tests['Item\\'s \"tagList\" property is an Array'] = Array.isArray(item.tagList);", + "tests['Item has \"seller\" property'] = item.hasOwnProperty('seller');", + "tests['Item has \"favorited\" property'] = item.hasOwnProperty('favorited');", + "tests['Item has \"favoritesCount\" property'] = item.hasOwnProperty('favoritesCount');", + "tests['favoritesCount is an integer'] = Number.isInteger(item.favoritesCount);", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + } + ] + }, + { + "name": "Comments", + "description": "", + "item": [{ + "name": "All Comments for Item", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"comments\" property'] = responseJSON.hasOwnProperty('comments');", + "", + " if(responseJSON.comments.length){", + " var comment = responseJSON.comments[0];", + "", + " tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", + " tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", + " tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", + " tests['\"createdAt\" property is an ISO 8601 timestamp'] = new Date(comment.createdAt).toISOString() === comment.createdAt;", + " tests['Comment has \"updatedAt\" property'] = comment.hasOwnProperty('updatedAt');", + " tests['\"updatedAt\" property is an ISO 8601 timestamp'] = new Date(comment.updatedAt).toISOString() === comment.updatedAt;", + " tests['Comment has \"seller\" property'] = comment.hasOwnProperty('seller');", + " }", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}/comments", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + }, + { + "name": "Create Comment for Item", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var responseJSON = JSON.parse(responseBody);", + "", + "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');", + "", + "var comment = responseJSON.comment || {};", + "", + "tests['Comment has \"id\" property'] = comment.hasOwnProperty('id');", + "tests['Comment has \"body\" property'] = comment.hasOwnProperty('body');", + "tests['Comment has \"createdAt\" property'] = comment.hasOwnProperty('createdAt');", + "tests['\"createdAt\" property is an ISO 8601 timestamp'] = new Date(comment.createdAt).toISOString() === comment.createdAt;", + "tests['Comment has \"seller\" property'] = comment.hasOwnProperty('seller');", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/items/{{slug}}/comments", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"comment\":{\"body\":\"Thank you so much!\"}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Delete Comment for Item", + "request": { + "url": "{{apiUrl}}/items/{{slug}}/comments/1", + "method": "DELETE", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + } + ] + }, + { + "name": "Profiles", + "description": "", + "item": [{ + "name": "Profile", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (!(environment.isIntegrationTest)) {", + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", + " ", + " var profile = responseJSON.profile || {};", + " ", + " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", + " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", + " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", + "}", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/profiles/johnjacob", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + }, + { + "name": "Follow Profile", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (!(environment.isIntegrationTest)) {", + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", + " ", + " var profile = responseJSON.profile || {};", + " ", + " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", + " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", + " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", + " tests['Profile\\'s \"following\" property is true'] = profile.following === true;", + "}", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/profiles/johnjacob/follow", + "method": "POST", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"john@jacob.com\"}}" + }, + "description": "" + }, + "response": [] + }, + { + "name": "Unfollow Profile", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (!(environment.isIntegrationTest)) {", + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + "", + " tests['Response contains \"profile\" property'] = responseJSON.hasOwnProperty('profile');", + " ", + " var profile = responseJSON.profile || {};", + " ", + " tests['Profile has \"username\" property'] = profile.hasOwnProperty('username');", + " tests['Profile has \"image\" property'] = profile.hasOwnProperty('image');", + " tests['Profile has \"following\" property'] = profile.hasOwnProperty('following');", + " tests['Profile\\'s \"following\" property is false'] = profile.following === false;", + "}", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/profiles/johnjacob/follow", + "method": "DELETE", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": {}, + "description": "" + }, + "response": [] + } + ] + }, + { + "name": "Tags", + "description": "", + "item": [{ + "name": "All Tags", + "event": [{ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var is200Response = responseCode.code === 200;", + "", + "tests['Response code is 200 OK'] = is200Response;", + "", + "if(is200Response){", + " var responseJSON = JSON.parse(responseBody);", + " ", + " tests['Response contains \"tags\" property'] = responseJSON.hasOwnProperty('tags');", + " tests['\"tags\" property returned as array'] = Array.isArray(responseJSON.tags);", + "}", + "" + ] + } + }], + "request": { + "url": "{{apiUrl}}/tags", + "method": "GET", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }] + }, + { + "name": "Cleanup", + "description": "", + "item": [{ + "name": "Delete Item", + "request": { + "url": "{{apiUrl}}/items/{{slug}}", + "method": "DELETE", + "header": [{ + "key": "Content-Type", + "value": "application/json", + "description": "" + }, + { + "key": "X-Requested-With", + "value": "XMLHttpRequest", + "description": "" + }, + { + "key": "Authorization", + "value": "Token {{token}}", + "description": "" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "description": "" + }, + "response": [] + }] + } + ] +} diff --git a/.framework/node/backend/tests/env-api-tests.postman.json b/.framework/node/backend/tests/env-api-tests.postman.json new file mode 100644 index 0000000..3ba2ebf --- /dev/null +++ b/.framework/node/backend/tests/env-api-tests.postman.json @@ -0,0 +1,14 @@ +{ + "id": "4aa60b52-97fc-456d-4d4f-14a350e95dff", + "name": "Anythink-Market API Tests - Environment", + "values": [{ + "enabled": true, + "key": "apiUrl", + "value": "http://localhost:3000/api", + "type": "text" + }], + "timestamp": 1505871382668, + "_postman_variable_scope": "environment", + "_postman_exported_at": "2017-09-20T01:36:34.835Z", + "_postman_exported_using": "Postman/5.2.0" +} diff --git a/.framework/node/backend/yarn.lock b/.framework/node/backend/yarn.lock new file mode 100644 index 0000000..d00f449 --- /dev/null +++ b/.framework/node/backend/yarn.lock @@ -0,0 +1,3648 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/bson@*": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@types/bson/-/bson-4.2.0.tgz#a2f71e933ff54b2c3bf267b67fa221e295a33337" + integrity sha512-ELCPqAdroMdcuxqwMgUpifQyRoTpyYCNr1V9xKyF40VsBobsj+BbWNRvwGchMgBPGqkw655ypkjj2MEF5ywVwg== + dependencies: + bson "*" + +"@types/mongodb@^3.5.27": + version "3.6.20" + resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.6.20.tgz#b7c5c580644f6364002b649af1c06c3c0454e1d2" + integrity sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ== + dependencies: + "@types/bson" "*" + "@types/node" "*" + +"@types/node@*": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.0.tgz#62797cee3b8b497f6547503b2312254d4fe3c2bb" + integrity sha512-eMhwJXc931Ihh4tkU+Y7GiLzT/y/DBNpNtr4yU9O2w3SYBsr9NaOPhQlLKRmoWtI54uNwuo0IOUFQjVOTZYRvw== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +accepts@~1.2.12: + version "1.2.13" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.2.13.tgz#e5f1f3928c6d95fd96558c36ec3d9d0de4a6ecea" + integrity sha1-5fHzkoxtlf2WVYw27D2dDeSm7Oo= + dependencies: + mime-types "~2.1.6" + negotiator "0.5.3" + +accepts@~1.3.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" + integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== + dependencies: + mime-types "~2.1.24" + negotiator "0.6.2" + +ajv@^5.1.0: + version "5.5.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" + integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU= + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + +align-text@^0.1.1, align-text@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" + integrity sha1-DNkKVhCT810KmSVsIrcGlDP60Rc= + dependencies: + kind-of "^3.0.2" + longest "^1.0.1" + repeat-string "^1.5.2" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= + +ansi-align@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" + integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38= + dependencies: + string-width "^2.0.0" + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= + +array-uniq@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +asn1@~0.2.3: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assert-plus@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" + integrity sha1-104bh+ev/A24qttwIfP+SBAasjQ= + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +async-each@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== + +async@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" + integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== + dependencies: + lodash "^4.17.10" + +async@^0.9.0: + version "0.9.2" + resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" + integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= + +async@^1.4.0: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= + +async@^2.0.1: + version "2.6.3" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" + integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + dependencies: + lodash "^4.17.14" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +aws-sign2@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" + integrity sha1-FDQt0428yU0OW4fXY81jYSwOeU8= + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.7.0.tgz#d4d0e9b9dbfca77bf08eeb0a8a471550fe39e289" + integrity sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w== + +aws4@^1.2.1, aws4@^1.6.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + +axios@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.25.0.tgz#349cfbb31331a9b4453190791760a8d35b093e0a" + integrity sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g== + dependencies: + follow-redirects "^1.14.7" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +base64-url@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/base64-url/-/base64-url-1.2.1.tgz#199fd661702a0e7b7dcae6e0698bb089c52f6d78" + integrity sha1-GZ/WYXAqDnt9yubgaYuwicUvbXg= + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +basic-auth@~1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-1.0.4.tgz#030935b01de7c9b94a824b29f3fccb750d3a5290" + integrity sha1-Awk1sB3nyblKgksp8/zLdQ06UpA= + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bl@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5" + integrity sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g== + dependencies: + readable-stream "^2.3.5" + safe-buffer "^5.1.1" + +bl@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.0.3.tgz#fc5421a28fd4226036c3b3891a66a25bc64d226e" + integrity sha1-/FQhoo/UImA2w7OJGmaiW8ZNIm4= + dependencies: + readable-stream "~2.0.5" + +bluebird@3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" + integrity sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA== + +bluebird@^2.6.2: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + integrity sha1-U0uQM8AiyVecVro7Plpcqvu2UOE= + +body-parser@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.15.0.tgz#8168abaeaf9e77e300f7b3aef4df4b46e9b21b35" + integrity sha1-gWirrq+ed+MA97Ou9N9LRumyGzU= + dependencies: + bytes "2.2.0" + content-type "~1.0.1" + debug "~2.2.0" + depd "~1.1.0" + http-errors "~1.4.0" + iconv-lite "0.4.13" + on-finished "~2.3.0" + qs "6.1.0" + raw-body "~2.1.5" + type-is "~1.6.11" + +boom@2.x.x: + version "2.10.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" + integrity sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8= + dependencies: + hoek "2.x.x" + +boom@4.x.x: + version "4.3.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31" + integrity sha1-T4owBctKfjiJ90kDD9JbluAdLjE= + dependencies: + hoek "4.x.x" + +boom@5.x.x: + version "5.2.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02" + integrity sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw== + dependencies: + hoek "4.x.x" + +boxen@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" + integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw== + dependencies: + ansi-align "^2.0.0" + camelcase "^4.0.0" + chalk "^2.0.1" + cli-boxes "^1.0.0" + string-width "^2.0.0" + term-size "^1.2.0" + widest-line "^2.0.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1, braces@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +bson@*: + version "4.6.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-4.6.0.tgz#15c3b39ba3940c3d915a0c44d51459f4b4fbf1b2" + integrity sha512-8jw1NU1hglS+Da1jDOUYuNcBJ4cNHCFIqzlwoFNnsTOg2R/ox0aTYcTiBN4dzRa9q7Cvy6XErh3L8ReTEb9AQQ== + dependencies: + buffer "^5.6.0" + +bson@^1.1.4: + version "1.1.6" + resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.6.tgz#fb819be9a60cd677e0853aee4ca712a785d6618a" + integrity sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg== + +btoa@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73" + integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g== + +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + +buffer@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +bytes@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.2.0.tgz#fd35464a403f6f9117c2de3609ecff9cae000588" + integrity sha1-/TVGSkA/b5EXwt42Cez/nK4ABYg= + +bytes@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.4.0.tgz#7d97196f9d5baf7f6935e25985549edd2a6c2339" + integrity sha1-fZcZb51br39pNeJZhVSe3SpsIzk= + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +camelcase@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" + integrity sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk= + +camelcase@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" + integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= + +capture-stack-trace@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d" + integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw== + +caseless@~0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7" + integrity sha1-cVuW6phBWTzDMGeSP17GDr2k99c= + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +center-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" + integrity sha1-qg0yYptu6XIgBBHL1EYckHvCt60= + dependencies: + align-text "^0.1.3" + lazy-cache "^1.0.3" + +chalk@^1.1.0, chalk@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +charset@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/charset/-/charset-1.0.1.tgz#8d59546c355be61049a8fa9164747793319852bd" + integrity sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg== + +chokidar@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" + integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== + dependencies: + anymatch "^2.0.0" + async-each "^1.0.1" + braces "^2.3.2" + glob-parent "^3.1.0" + inherits "^2.0.3" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^3.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.2.1" + upath "^1.1.1" + optionalDependencies: + fsevents "^1.2.7" + +ci-info@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" + integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== + +circular-json@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d" + integrity sha1-vos2rvzN6LPKeqLWr8B6NyQsDS0= + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +cli-boxes@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" + integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM= + +cli-progress@1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-1.8.0.tgz#5e8afc310f2058fbe33e9006e31c71c1c3b5da7f" + integrity sha1-Xor8MQ8gWPvjPpAG4xxxwcO12n8= + dependencies: + colors "^1.1.2" + +cli-table3@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.4.0.tgz#a7fd50f011d734e3f16403cfcbedbea97659e417" + integrity sha512-o0slI6EFJNI2aKE9jG1bVN6jXJG2vjzYsGhyd9RqRV/YiiEmzSwNNXb5qJmfLDSOdvfA6sUvdKVvi3p3Y1apxA== + dependencies: + kind-of "^3.0.4" + object-assign "^4.1.0" + string-width "^1.0.1" + optionalDependencies: + colors "^1.1.2" + +cliui@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" + integrity sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE= + dependencies: + center-align "^0.1.1" + right-align "^0.1.1" + wordwrap "0.0.2" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +colors@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.0.tgz#5f20c9fef6945cb1134260aab33bfbdc8295e04e" + integrity sha512-EDpX3a7wHMWFA7PUHWPHNWqOxIIRSJetuwl0AS5Oi/5FMV8kWm69RTlgm00GKjBO1xFHMtBbL49yRtMMdticBw== + +colors@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +combined-stream@^1.0.5, combined-stream@^1.0.6, combined-stream@~1.0.5: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.16.0.tgz#f16390593996ceb4f3eeb020b31d78528f7f8a50" + integrity sha512-sVXqklSaotK9at437sFlFpyOcJonxe0yST/AG9DkQKUdIE6IqGIMv4SfAQSKaJbSdVEJYItASCrBiVQHq1HQew== + +commander@^2.9.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +configstore@^3.0.0: + version "3.1.5" + resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f" + integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA== + dependencies: + dot-prop "^4.2.1" + graceful-fs "^4.1.2" + make-dir "^1.0.0" + unique-string "^1.0.0" + write-file-atomic "^2.0.0" + xdg-basedir "^3.0.0" + +content-disposition@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.1.tgz#87476c6a67c8daa87e32e87616df883ba7fb071b" + integrity sha1-h0dsamfI2qh+Muh2Ft+IO6f7Bxs= + +content-type@~1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= + +cookie@0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.1.5.tgz#6ab9948a4b1ae21952cd2588530a4722d4044d7c" + integrity sha1-armUiksa4hlSzSWIUwpHItQETXw= + +cookie@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.2.3.tgz#1a59536af68537a21178a01346f87cb059d2ae5c" + integrity sha1-GllTavaFN6IReKATRvh8sFnSrlw= + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.7.1.tgz#3c2e50a58af9ef8c89bee21226b099be1f02739b" + integrity sha1-PC5QpYr574yJvuISJrCZvh8Cc5s= + dependencies: + vary "^1" + +crc@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/crc/-/crc-3.4.0.tgz#4258e351613a74ef1153dfcb05e820c3e9715d7f" + integrity sha1-QljjUWE6dO8RU9/LBeggw+lxXX8= + +create-error-class@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6" + integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y= + dependencies: + capture-stack-trace "^1.0.0" + +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +cryptiles@2.x.x: + version "2.0.5" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" + integrity sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g= + dependencies: + boom "2.x.x" + +cryptiles@3.x.x: + version "3.1.4" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.4.tgz#769a68c95612b56faadfcebf57ac86479cbe8322" + integrity sha512-8I1sgZHfVwcSOY6mSGpVU3lw/GSIZvusg8dD2+OGehCJpOhQRLNcH0qb9upQnOH4XhgxxFJSg6E2kx95deb1Tw== + dependencies: + boom "5.x.x" + +crypto-js@3.1.9-1: + version "3.1.9-1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" + integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg= + +crypto-random-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" + integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= + +csv-parse@1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-1.3.3.tgz#d1cfd8743c2f849a0abb2fd544db56695d19a490" + integrity sha1-0c/YdDwvhJoKuy/VRNtWaV0ZpJA= + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +dbug@~0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/dbug/-/dbug-0.4.2.tgz#32b4b3105e8861043a6f9ac755d80e542d365b31" + integrity sha1-MrSzEF6IYQQ6b5rHVdgOVC02WzE= + +debug@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@~2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" + integrity sha1-+HBX6ZWxofauaklgZkE3vFbwOdo= + dependencies: + ms "0.7.1" + +decamelize@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +denque@^1.4.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" + integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== + +depd@~1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +destroy@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +domelementtype@1, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4" + integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ== + dependencies: + is-obj "^1.0.0" + +dotenv@^8.2.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" + integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== + +duplexer3@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" + integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +ejs@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.4.1.tgz#82e15b1b2a1f948b18097476ba2bd7c66f4d1566" + integrity sha1-guFbGyoflIsYCXR2uivXxm9NFWY= + +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +errorhandler@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/errorhandler/-/errorhandler-1.4.3.tgz#b7b70ed8f359e9db88092f2d20c0f831420ad83f" + integrity sha1-t7cO2PNZ6duICS8tIMD4MUIK2D8= + dependencies: + accepts "~1.3.0" + escape-html "~1.0.3" + +escape-html@1.0.3, escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +etag@~1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.7.0.tgz#03d30b5f67dd6e632d2945d30d6652731a34d5d8" + integrity sha1-A9MLX2fdbmMtKUXTDWZScxo01dg= + +eventemitter3@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" + integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== + +execa@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" + integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +express-async-handler@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/express-async-handler/-/express-async-handler-1.2.0.tgz#ffc9896061d90f8d2e71a2d2b8668db5b0934391" + integrity sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w== + +express-jwt@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/express-jwt/-/express-jwt-3.3.0.tgz#d10e17244225b1968d20137ff77fc7488c88f494" + integrity sha1-0Q4XJEIlsZaNIBN/93/HSIyI9JQ= + dependencies: + async "^0.9.0" + express-unless "^0.3.0" + jsonwebtoken "^5.0.0" + lodash "~3.10.1" + +express-session@1.13.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.13.0.tgz#8ac3b5c0188b48382851d88207b8e7746efb4011" + integrity sha1-isO1wBiLSDgoUdiCB7jndG77QBE= + dependencies: + cookie "0.2.3" + cookie-signature "1.0.6" + crc "3.4.0" + debug "~2.2.0" + depd "~1.1.0" + on-headers "~1.0.1" + parseurl "~1.3.0" + uid-safe "~2.0.0" + utils-merge "1.0.0" + +express-unless@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/express-unless/-/express-unless-0.3.1.tgz#2557c146e75beb903e2d247f9b5ba01452696e20" + integrity sha1-JVfBRudb65A+LSR/m1ugFFJpbiA= + +express@4.13.4: + version "4.13.4" + resolved "https://registry.yarnpkg.com/express/-/express-4.13.4.tgz#3c0b76f3c77590c8345739061ec0bd3ba067ec24" + integrity sha1-PAt288d1kMg0VzkGHsC9O6Bn7CQ= + dependencies: + accepts "~1.2.12" + array-flatten "1.1.1" + content-disposition "0.5.1" + content-type "~1.0.1" + cookie "0.1.5" + cookie-signature "1.0.6" + debug "~2.2.0" + depd "~1.1.0" + escape-html "~1.0.3" + etag "~1.7.0" + finalhandler "0.4.1" + fresh "0.3.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.1" + path-to-regexp "0.1.7" + proxy-addr "~1.0.10" + qs "4.0.0" + range-parser "~1.0.3" + send "0.13.1" + serve-static "~1.10.2" + type-is "~1.6.6" + utils-merge "1.0.0" + vary "~1.0.1" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@~3.0.0, extend@~3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" + integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== + +fast-deep-equal@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" + integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ= + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +file-type@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" + integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek= + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +filesize@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +finalhandler@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.4.1.tgz#85a17c6c59a94717d262d61230d4b0ebe3d4a14d" + integrity sha1-haF8bFmpRxfSYtYSMNSw6+PUoU0= + dependencies: + debug "~2.2.0" + escape-html "~1.0.3" + on-finished "~2.3.0" + unpipe "~1.0.0" + +follow-redirects@^1.14.7: + version "1.14.7" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" + integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ== + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~1.0.0-rc3: + version "1.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-1.0.1.tgz#ae315db9a4907fa065502304a66d7733475ee37c" + integrity sha1-rjFduaSQf6BlUCMEpm13M0de43w= + dependencies: + async "^2.0.1" + combined-stream "^1.0.5" + mime-types "^2.1.11" + +form-data@~2.3.1: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +forwarded@~0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" + integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fresh@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" + integrity sha1-ZR+DjiJCTnVm3hYdg1jKoZn4PU8= + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.2.7: + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== + dependencies: + bindings "^1.5.0" + nan "^2.12.1" + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +generate-function@^2.0.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + +generate-object-property@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" + integrity sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA= + dependencies: + is-property "^1.0.0" + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-dirs@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" + integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= + dependencies: + ini "^1.3.4" + +got@^6.7.1: + version "6.7.1" + resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0" + integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA= + dependencies: + create-error-class "^3.0.0" + duplexer3 "^0.1.4" + get-stream "^3.0.0" + is-redirect "^1.0.0" + is-retry-allowed "^1.0.0" + is-stream "^1.0.0" + lowercase-keys "^1.0.0" + safe-buffer "^5.0.1" + timed-out "^4.0.0" + unzip-response "^2.0.1" + url-parse-lax "^1.0.0" + +graceful-fs@^4.1.11, graceful-fs@^4.1.2: + version "4.2.8" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" + integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== + +handlebars@4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc" + integrity sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw= + dependencies: + async "^1.4.0" + optimist "^0.6.1" + source-map "^0.4.4" + optionalDependencies: + uglify-js "^2.6" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d" + integrity sha1-zcvAgYgmWtEZtqWnyKtw7s+10n0= + dependencies: + chalk "^1.1.1" + commander "^2.9.0" + is-my-json-valid "^2.12.4" + pinkie-promise "^2.0.0" + +har-validator@~5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd" + integrity sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0= + dependencies: + ajv "^5.1.0" + har-schema "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hawk@6.0.2, hawk@~6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038" + integrity sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ== + dependencies: + boom "4.x.x" + cryptiles "3.x.x" + hoek "4.x.x" + sntp "2.x.x" + +hawk@~3.1.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" + integrity sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ= + dependencies: + boom "2.x.x" + cryptiles "2.x.x" + hoek "2.x.x" + sntp "1.x.x" + +hoek@2.x.x: + version "2.16.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" + integrity sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0= + +hoek@4.x.x: + version "4.2.1" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb" + integrity sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA== + +htmlparser2@^3.9.0: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +http-errors@~1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.3.1.tgz#197e22cdebd4198585e8694ef6786197b91ed942" + integrity sha1-GX4izevUGYWF6GlO9nhhl7ke2UI= + dependencies: + inherits "~2.0.1" + statuses "1" + +http-errors@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.4.0.tgz#6c0242dea6b3df7afda153c71089b31c6e82aabf" + integrity sha1-bAJC3qaz33r9oVPHEImzHG6Cqr8= + dependencies: + inherits "2.0.1" + statuses ">= 1.2.1 < 2" + +http-reasons@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/http-reasons/-/http-reasons-0.1.0.tgz#a953ca670078669dde142ce899401b9d6e85d3b4" + integrity sha1-qVPKZwB4Zp3eFCzomUAbnW6F07Q= + +http-signature@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" + integrity sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8= + dependencies: + assert-plus "^0.2.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +httpntlm@1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/httpntlm/-/httpntlm-1.7.6.tgz#6991e8352836007d67101b83db8ed0f915f906d0" + integrity sha1-aZHoNSg2AH1nEBuD247Q+RX5BtA= + dependencies: + httpreq ">=0.4.22" + underscore "~1.7.0" + +httpreq@>=0.4.22: + version "0.5.2" + resolved "https://registry.yarnpkg.com/httpreq/-/httpreq-0.5.2.tgz#be6777292fa1038d7771d7c01d9a5e1219de951c" + integrity sha512-2Jm+x9WkExDOeFRrdBCBSpLPT5SokTcRHkunV3pjKmX/cx6av8zQ0WtHUMDrYb6O4hBFzNU6sxJEypvRUVYKnw== + +iconv-lite@0.4.13: + version "0.4.13" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" + integrity sha1-H4irpKsLFQjoMSrMOTRfNumS4vI= + +iconv-lite@0.4.22: + version "0.4.22" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.22.tgz#c6b16b9d05bc6c307dc9303a820412995d2eea95" + integrity sha512-1AinFBeDTnsvVEP+V1QBlHpM1UZZl7gWB6fcz7B1Ho+LI1dUh2sSrxoCfVt2PinRHzXAziSniEV3P7JbTDHcXA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= + +import-lazy@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" + integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini@^1.3.4, ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +intel@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/intel/-/intel-1.2.0.tgz#11d1147eb6b3f4582bdf5337b37d541584e9e41e" + integrity sha1-EdEUfraz9Fgr31M3s31UFYTp5B4= + dependencies: + chalk "^1.1.0" + dbug "~0.4.2" + stack-trace "~0.0.9" + strftime "~0.10.0" + symbol "~0.3.1" + utcstring "~0.1.0" + +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + +ipaddr.js@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.0.5.tgz#5fa78cf301b825c78abc3042d812723049ea23c7" + integrity sha1-X6eM8wG4JceKvDBC2BJyMEnqI8c= + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + dependencies: + binary-extensions "^1.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-ci@^1.0.10: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" + integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== + dependencies: + ci-info "^1.5.0" + +is-core-module@^2.2.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548" + integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw== + dependencies: + has "^1.0.3" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-installed-globally@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80" + integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA= + dependencies: + global-dirs "^0.1.0" + is-path-inside "^1.0.0" + +is-my-ip-valid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz#7b351b8e8edd4d3995d4d066680e664d94696824" + integrity sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ== + +is-my-json-valid@^2.12.4: + version "2.20.6" + resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.20.6.tgz#a9d89e56a36493c77bda1440d69ae0dc46a08387" + integrity sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw== + dependencies: + generate-function "^2.0.0" + generate-object-property "^1.1.0" + is-my-ip-valid "^1.0.0" + jsonpointer "^5.0.0" + xtend "^4.0.0" + +is-npm@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" + integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ= + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-obj@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + +is-path-inside@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" + integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= + dependencies: + path-is-inside "^1.0.1" + +is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-property@^1.0.0, is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ= + +is-redirect@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" + integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= + +is-retry-allowed@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" + integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== + +is-stream@^1.0.0, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +isarray@1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isemail@1.x.x: + version "1.2.0" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-1.2.0.tgz#be03df8cc3e29de4d2c5df6501263f1fa4595e9a" + integrity sha1-vgPfjMPineTSxd9lASY/H6RZXpo= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +joi@^6.10.1: + version "6.10.1" + resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" + integrity sha1-TVDDGAeRIgAP5fFq8f+OGRe3fgY= + dependencies: + hoek "2.x.x" + isemail "1.x.x" + moment "2.x.x" + topo "1.x.x" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-schema-traverse@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" + integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A= + +json-schema@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +jsonpointer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.0.tgz#f802669a524ec4805fa7389eadbc9921d5dc8072" + integrity sha512-PNYZIdMjVIvVgDSYKTT63Y+KZ6IZvGRNNWcxwD+GNnUz1MKPfv30J8ueCjdwcN0nDx2SlshgyB7Oy0epAzVRRg== + +jsonwebtoken@7.1.9: + version "7.1.9" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.1.9.tgz#847804e5258bec5a9499a8dc4a5e7a3bae08d58a" + integrity sha1-hHgE5SWL7FqUmajcSl56O64I1Yo= + dependencies: + joi "^6.10.1" + jws "^3.1.3" + lodash.once "^4.0.0" + ms "^0.7.1" + xtend "^4.0.1" + +jsonwebtoken@^5.0.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-5.7.0.tgz#1c90f9a86ce5b748f5f979c12b70402b4afcddb4" + integrity sha1-HJD5qGzlt0j1+XnBK3BAK0r83bQ= + dependencies: + jws "^3.0.0" + ms "^0.7.1" + xtend "^4.0.1" + +jsprim@^1.2.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" + integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.0.0, jws@^3.1.3: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +kareem@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.2.tgz#78c4508894985b8d38a0dc15e1a8e11078f2ca93" + integrity sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ== + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.0.4, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +latest-version@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" + integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU= + dependencies: + package-json "^4.0.0" + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= + +liquid-json@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/liquid-json/-/liquid-json-0.3.1.tgz#9155a18136d8a6b2615e5f16f9a2448ab6b50eea" + integrity sha1-kVWhgTbYprJhXl8W+aJEira1Duo= + +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + +lodash.escaperegexp@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" + integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= + +lodash.foreach@^4.1.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" + integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= + +lodash.get@^4.0.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.mergewith@^4.6.0: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + +lodash@4.17.10: + version "4.17.10" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" + integrity sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg== + +lodash@4.17.9: + version "4.17.9" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.9.tgz#9c056579af0bdbb4322e23c836df13ef2b271cb7" + integrity sha512-vuRLquvot5sKUldMBumG0YqLvX6m/RGBBOmqb3CWR/MC/QvvD1cTH1fOqxz2FJAQeoExeUdX5Gu9vP2EP6ik+Q== + +lodash@^4.17.10, lodash@^4.17.14: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +lodash@~3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" + integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y= + +longest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" + integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= + +lowercase-keys@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" + integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== + +lru-cache@^4.0.1: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +make-dir@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" + integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== + dependencies: + pify "^3.0.0" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +marked@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-0.4.0.tgz#9ad2c2a7a1791f10a852e0112f77b571dce10c66" + integrity sha512-tMsdNBgOsrUophCAFQl0XPe6Zqk/uy9gnue+jIIKhykO51hxyu6uNx7zBPy0+y/WKYVZZMspV9YeXLNdKk+iYw== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + +memory-pager@^1.0.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" + integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= + +method-override@2.3.5: + version "2.3.5" + resolved "https://registry.yarnpkg.com/method-override/-/method-override-2.3.5.tgz#2cd5cdbff00c3673d7ae345119a812a5d95b8c8e" + integrity sha1-LNXNv/AMNnPXrjRRGagSpdlbjI4= + dependencies: + debug "~2.2.0" + methods "~1.1.1" + parseurl "~1.3.0" + vary "~1.0.1" + +methods@1.1.2, methods@~1.1.1, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + +micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +mime-db@1.51.0: + version "1.51.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c" + integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g== + +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== + +mime-format@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mime-format/-/mime-format-2.0.0.tgz#e29f8891e284d78270246f0050d6834bdbbe1332" + integrity sha1-4p+IkeKE14JwJG8AUNaDS9u+EzI= + dependencies: + charset "^1.0.0" + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== + dependencies: + mime-db "~1.33.0" + +mime-types@^2.1.11, mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.6, mime-types@~2.1.7: + version "2.1.34" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24" + integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A== + dependencies: + mime-db "1.51.0" + +mime@1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" + integrity sha1-EV+eO2s9rylZmDyzjxSaLUDrXVM= + +minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@^1.2.0: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +moment@2.x.x: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + +mongodb@3.6.6: + version "3.6.6" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.6.tgz#92e3658f45424c34add3003e3046c1535c534449" + integrity sha512-WlirMiuV1UPbej5JeCMqE93JRfZ/ZzqE7nJTwP85XzjAF4rRSeq2bGCb1cjfoHLOF06+HxADaPGqT0g3SbVT1w== + dependencies: + bl "^2.2.1" + bson "^1.1.4" + denque "^1.4.1" + optional-require "^1.0.2" + safe-buffer "^5.1.2" + optionalDependencies: + saslprep "^1.0.0" + +mongoose-legacy-pluralize@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4" + integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ== + +mongoose-unique-validator@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mongoose-unique-validator/-/mongoose-unique-validator-3.1.0.tgz#10d6fa10ccf5515461e3b5693f193d227546d60b" + integrity sha512-UsBBlFapip8gc8x1h+nLWnkOy+GTy9Z+zmTyZ35icLV3EoLIVz180vJzepfMM9yBy2AJh+maeuoM8CWtqejGUg== + dependencies: + lodash.foreach "^4.1.0" + lodash.get "^4.0.2" + lodash.merge "^4.6.2" + +mongoose@5.12.5: + version "5.12.5" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.12.5.tgz#70d11d3e68a3aeeb6960262633e1ba80cb620385" + integrity sha512-VVoqiELZcoI2HhHDuPpfN3qmExrtIeXSWNb1nihf4w1SJoWGXilU/g2cQgeeSMc2vAHSZd5Nv2sNPvbZHFw+pg== + dependencies: + "@types/mongodb" "^3.5.27" + bson "^1.1.4" + kareem "2.3.2" + mongodb "3.6.6" + mongoose-legacy-pluralize "1.0.2" + mpath "0.8.3" + mquery "3.2.5" + ms "2.1.2" + regexp-clone "1.0.0" + safe-buffer "5.2.1" + sift "7.0.1" + sliced "1.0.1" + +morgan@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.7.0.tgz#eb10ca8e50d1abe0f8d3dad5c0201d052d981c62" + integrity sha1-6xDKjlDRq+D409rVwCAdBS2YHGI= + dependencies: + basic-auth "~1.0.3" + debug "~2.2.0" + depd "~1.1.0" + on-finished "~2.3.0" + on-headers "~1.0.1" + +mpath@0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.8.3.tgz#828ac0d187f7f42674839d74921970979abbdd8f" + integrity sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA== + +mquery@3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.5.tgz#8f2305632e4bb197f68f60c0cffa21aaf4060c51" + integrity sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A== + dependencies: + bluebird "3.5.1" + debug "3.1.0" + regexp-clone "^1.0.0" + safe-buffer "5.1.2" + sliced "1.0.1" + +ms@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" + integrity sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg= + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@^0.7.1: + version "0.7.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.3.tgz#708155a5e44e33f5fd0fc53e81d0d40a91be1fff" + integrity sha1-cIFVpeROM/X9D8U+gdDUCpG+H/8= + +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nan@^2.12.1: + version "2.15.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" + integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +negotiator@0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.5.3.tgz#269d5c476810ec92edbe7b6c2f28316384f9a7e8" + integrity sha1-Jp1cR2gQ7JLtvntsLygxY4T5p+g= + +negotiator@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" + integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== + +newman@^3.8.2: + version "3.10.0" + resolved "https://registry.yarnpkg.com/newman/-/newman-3.10.0.tgz#24bb43963e25bb79a4fc158cd76bf20eaa179f06" + integrity sha512-8dr3kUedx/D4a/tiysvEjEQ+D+lLA/sgPASN33AiRyTKtdqzeVFuuBZYb3Jb+0TBd84Y3Qk8t24GuTY22HJN4g== + dependencies: + async "2.6.1" + cli-progress "1.8.0" + cli-table3 "0.4.0" + colors "1.3.0" + commander "2.16.0" + csv-parse "1.3.3" + eventemitter3 "3.1.0" + filesize "3.6.1" + handlebars "4.0.11" + lodash "4.17.9" + mkdirp "0.5.1" + parse-json "4.0.0" + postman-collection "3.1.1" + postman-collection-transformer "2.5.10" + postman-request "2.86.1-postman.1" + postman-runtime "7.2.0" + pretty-ms "3.2.0" + semver "5.5.0" + serialised-error "1.1.3" + shelljs "0.8.2" + word-wrap "1.2.3" + xmlbuilder "10.0.0" + +node-oauth1@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/node-oauth1/-/node-oauth1-1.2.2.tgz#fffb2813a88c2770711332ad0e5487b4927644a4" + integrity sha512-f2XC7Y68wJq6+s+LJn/yUq5Gqg9Y9zwIz2zY6vUyS8xzawnSWhXKOMJepLwvptjPl8IjVxtWh7iI9dbdKGSw4g== + dependencies: + crypto-js "3.1.9-1" + +node-uuid@~1.4.7: + version "1.4.8" + resolved "https://registry.yarnpkg.com/node-uuid/-/node-uuid-1.4.8.tgz#b040eb0923968afabf8d32fb1f17f1167fdab907" + integrity sha1-sEDrCSOWivq/jTL7HxfxFn/auQc= + +nodemon@^1.11.0: + version "1.19.4" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.19.4.tgz#56db5c607408e0fdf8920d2b444819af1aae0971" + integrity sha512-VGPaqQBNk193lrJFotBU8nvWZPqEZY2eIzymy2jjY0fJ9qIsxA0sxQ8ATPl0gZC645gijYEc1jtZvpS8QWzJGQ== + dependencies: + chokidar "^2.1.8" + debug "^3.2.6" + ignore-by-default "^1.0.1" + minimatch "^3.0.4" + pstree.remy "^1.1.7" + semver "^5.7.1" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.2" + update-notifier "^2.5.0" + +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4= + dependencies: + abbrev "1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +oauth-sign@~0.8.0, oauth-sign@~0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" + integrity sha1-Rqarfwrq2N6unsBWV4C31O/rnUM= + +object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-hash@^1.1.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-1.3.1.tgz#fde452098a951cb145f039bb7d455449ddc126df" + integrity sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + dependencies: + ee-first "1.1.1" + +on-headers@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +optimist@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" + integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= + dependencies: + minimist "~0.0.1" + wordwrap "~0.0.2" + +optional-require@^1.0.2: + version "1.1.8" + resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.1.8.tgz#16364d76261b75d964c482b2406cb824d8ec44b7" + integrity sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA== + dependencies: + require-at "^1.0.6" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +package-json@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed" + integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0= + dependencies: + got "^6.7.1" + registry-auth-token "^3.0.1" + registry-url "^3.0.3" + semver "^5.1.0" + +parse-json@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse-ms@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-1.0.1.tgz#56346d4749d78f23430ca0c713850aef91aa361d" + integrity sha1-VjRtR0nXjyNDDKDHE4UK75GqNh0= + +parseurl@~1.3.0, parseurl@~1.3.1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +passport-local@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" + integrity sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4= + dependencies: + passport-strategy "1.x.x" + +passport-strategy@1.x.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= + +passport@0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.3.2.tgz#9dd009f915e8fe095b0124a01b8f82da07510102" + integrity sha1-ndAJ+RXo/glbASSgG4+C2gdRAQI= + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-is-inside@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= + +path-key@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-parse@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= + +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +postcss@^6.0.14: + version "6.0.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" + integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +postman-collection-transformer@2.5.10: + version "2.5.10" + resolved "https://registry.yarnpkg.com/postman-collection-transformer/-/postman-collection-transformer-2.5.10.tgz#cecf07b7cdac58b09d7a3e7eae0af3e47c6f7cc4" + integrity sha512-2Pm0Z6v9IfqYhZciYW9i3ZUqOkLIf/AO2Ll389G0LlHJ/qg82sFhL0V4wUI1JQE6nd4eLBiUwhdPEPlHPQIWjQ== + dependencies: + commander "2.16.0" + inherits "2.0.3" + intel "1.2.0" + lodash "4.17.10" + semver "5.5.0" + strip-json-comments "2.0.1" + +postman-collection@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postman-collection/-/postman-collection-3.1.1.tgz#9042c1e7891f3f319566fd05f6f2aeeb51bc8d45" + integrity sha512-0Q9BpVVdquv4Wf/Kpvf8LgLADsnZW8g4lGouBncD2pn+mHzL72oWJmD9/kV56wp4SuQl0a1OZNuUYkK9fYPxOA== + dependencies: + escape-html "1.0.3" + file-type "3.9.0" + http-reasons "0.1.0" + iconv-lite "0.4.22" + liquid-json "0.3.1" + lodash "4.17.10" + marked "0.4.0" + mime-format "2.0.0" + mime-types "2.1.18" + postman-url-encoder "1.0.1" + sanitize-html "1.18.2" + semver "5.5.0" + uuid "3.3.2" + +postman-request@2.86.1-postman.1: + version "2.86.1-postman.1" + resolved "https://registry.yarnpkg.com/postman-request/-/postman-request-2.86.1-postman.1.tgz#bc43b753771e8fdcbad95f1436881f81e6c5bef2" + integrity sha512-HzzRbCLcOItaFhhvYiv0/LWShEZ4Lir8ZCL2OiQ8pkpirKM9u7BUQ4OgqNzTExt3m8NWg60f19eQ0hk1cNphLg== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.6.0" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.1" + forever-agent "~0.6.1" + form-data "~2.3.1" + har-validator "~5.0.3" + hawk "~6.0.2" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.17" + oauth-sign "~0.8.2" + performance-now "^2.1.0" + postman-url-encoder "1.0.1" + qs "~6.5.1" + safe-buffer "^5.1.1" + stream-length "^1.0.2" + tough-cookie "~2.3.3" + tunnel-agent "^0.6.0" + uuid "^3.1.0" + +postman-runtime@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/postman-runtime/-/postman-runtime-7.2.0.tgz#9d7796fd6981826b8abb887a02370059a02a04e2" + integrity sha512-penzRSjXckHeGXMP6NxvJVLbhxDa47Uei8RIbzf4gEV+1qTZ5qp9QppW2yWPNb5SSW1Z113t6LGKlpVR+plZMQ== + dependencies: + async "2.6.1" + aws4 "1.7.0" + btoa "1.2.1" + crypto-js "3.1.9-1" + eventemitter3 "3.1.0" + hawk "6.0.2" + http-reasons "0.1.0" + httpntlm "1.7.6" + inherits "2.0.3" + lodash "4.17.10" + node-oauth1 "1.2.2" + postman-collection "3.1.1" + postman-request "2.86.1-postman.1" + postman-sandbox "3.1.1" + resolve-from "4.0.0" + serialised-error "1.1.3" + uuid "3.3.2" + +postman-sandbox@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postman-sandbox/-/postman-sandbox-3.1.1.tgz#31ed0a97e9a2c803166a2080fe879a3377470e0f" + integrity sha512-bch46g1LfPnCeCTYQXKlYDmrnTljAPS74a12z5XCS2lJ4veIitX8y4b+mBZSxzMZ05tIZrUTDv+XoyZbRlpagw== + dependencies: + inherits "2.0.3" + lodash "4.17.10" + uuid "3.3.2" + uvm "1.7.3" + +postman-url-encoder@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postman-url-encoder/-/postman-url-encoder-1.0.1.tgz#a094a42e9415ff0bbfdce0eaa8e6011d449ee83c" + integrity sha1-oJSkLpQV/wu/3ODqqOYBHUSe6Dw= + +prepend-http@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= + +pretty-ms@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-3.2.0.tgz#87a8feaf27fc18414d75441467d411d6e6098a25" + integrity sha512-ZypexbfVUGTFxb0v+m1bUyy92DHe5SyYlnyY0msyms5zd3RwyvNgyxZZsXXgoyzlxjx5MiqtXUdhUfvQbe0A2Q== + dependencies: + parse-ms "^1.0.0" + +process-nextick-args@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" + integrity sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M= + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +proxy-addr@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.0.10.tgz#0d40a82f801fc355567d2ecb65efe3f077f121c5" + integrity sha1-DUCoL4Afw1VWfS7LZe/j8HfxIcU= + dependencies: + forwarded "~0.1.0" + ipaddr.js "1.0.5" + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + +pstree.remy@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +qs@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-4.0.0.tgz#c31d9b74ec27df75e543a86c78728ed8d4623607" + integrity sha1-wx2bdOwn33XlQ6hseHKO2NRiNgc= + +qs@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.1.0.tgz#ec1d1626b24278d99f0fdf4549e524e24eceeb26" + integrity sha1-7B0WJrJCeNmfD99FSeUk4k7O6yY= + +qs@~6.0.2: + version "6.0.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.0.4.tgz#51019d84720c939b82737e84556a782338ecea7b" + integrity sha1-UQGdhHIMk5uCc36EVWp4Izjs6ns= + +qs@~6.5.1: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +range-parser@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.0.3.tgz#6872823535c692e2c2a0103826afd82c2e0ff175" + integrity sha1-aHKCNTXGkuLCoBA4Jq/YLC4P8XU= + +raw-body@~2.1.5: + version "2.1.7" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.1.7.tgz#adfeace2e4fb3098058014d08c072dcc59758774" + integrity sha1-rf6s4uT7MJgFgBTQjActzFl1h3Q= + dependencies: + bytes "2.4.0" + iconv-lite "0.4.13" + unpipe "1.0.0" + +rc@^1.0.1, rc@^1.1.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@^2.0.2, readable-stream@^2.3.5: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@~2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" + integrity sha1-j5A0HmilPMySh4jaz80Rs265t44= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + string_decoder "~0.10.x" + util-deprecate "~1.0.1" + +readdirp@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= + dependencies: + resolve "^1.1.6" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexp-clone@1.0.0, regexp-clone@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-1.0.0.tgz#222db967623277056260b992626354a04ce9bf63" + integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw== + +registry-auth-token@^3.0.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e" + integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A== + dependencies: + rc "^1.1.6" + safe-buffer "^5.0.1" + +registry-url@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" + integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI= + dependencies: + rc "^1.0.1" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== + +repeat-string@^1.5.2, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +request@2.69.0: + version "2.69.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.69.0.tgz#cf91d2e000752b1217155c005241911991a2346a" + integrity sha1-z5HS4AB1KxIXFVwAUkGRGZGiNGo= + dependencies: + aws-sign2 "~0.6.0" + aws4 "^1.2.1" + bl "~1.0.0" + caseless "~0.11.0" + combined-stream "~1.0.5" + extend "~3.0.0" + forever-agent "~0.6.1" + form-data "~1.0.0-rc3" + har-validator "~2.0.6" + hawk "~3.1.0" + http-signature "~1.1.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.7" + node-uuid "~1.4.7" + oauth-sign "~0.8.0" + qs "~6.0.2" + stringstream "~0.0.4" + tough-cookie "~2.2.0" + tunnel-agent "~0.4.1" + +require-at@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/require-at/-/require-at-1.0.6.tgz#9eb7e3c5e00727f5a4744070a7f560d4de4f6e6a" + integrity sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g== + +resolve-from@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@^1.1.6: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +right-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" + integrity sha1-YTObci/mo1FWiSENJOFMlhSGE+8= + dependencies: + align-text "^0.1.1" + +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sanitize-html@1.18.2: + version "1.18.2" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.18.2.tgz#61877ba5a910327e42880a28803c2fbafa8e4642" + integrity sha512-52ThA+Z7h6BnvpSVbURwChl10XZrps5q7ytjTwWcIe9bmJwnVP6cpEVK2NvDOUhGupoqAvNbUz3cpnJDp4+/pg== + dependencies: + chalk "^2.3.0" + htmlparser2 "^3.9.0" + lodash.clonedeep "^4.5.0" + lodash.escaperegexp "^4.1.2" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.mergewith "^4.6.0" + postcss "^6.0.14" + srcset "^1.0.0" + xtend "^4.0.0" + +saslprep@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226" + integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag== + dependencies: + sparse-bitfield "^3.0.3" + +semver-diff@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" + integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY= + dependencies: + semver "^5.0.3" + +semver@5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" + integrity sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA== + +semver@^5.0.3, semver@^5.1.0, semver@^5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +send@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/send/-/send-0.13.1.tgz#a30d5f4c82c8a9bae9ad00a1d9b1bdbe6f199ed7" + integrity sha1-ow1fTILIqbrprQCh2bG9vm8Zntc= + dependencies: + debug "~2.2.0" + depd "~1.1.0" + destroy "~1.0.4" + escape-html "~1.0.3" + etag "~1.7.0" + fresh "0.3.0" + http-errors "~1.3.1" + mime "1.3.4" + ms "0.7.1" + on-finished "~2.3.0" + range-parser "~1.0.3" + statuses "~1.2.1" + +send@0.13.2: + version "0.13.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.13.2.tgz#765e7607c8055452bba6f0b052595350986036de" + integrity sha1-dl52B8gFVFK7pvCwUllTUJhgNt4= + dependencies: + debug "~2.2.0" + depd "~1.1.0" + destroy "~1.0.4" + escape-html "~1.0.3" + etag "~1.7.0" + fresh "0.3.0" + http-errors "~1.3.1" + mime "1.3.4" + ms "0.7.1" + on-finished "~2.3.0" + range-parser "~1.0.3" + statuses "~1.2.1" + +serialised-error@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/serialised-error/-/serialised-error-1.1.3.tgz#8a4c466b29c26ff11016eaf1b5fa2b87ca4cd8b5" + integrity sha512-vybp3GItaR1ZtO2nxZZo8eOo7fnVaNtP3XE2vJKgzkKR2bagCkdJ1EpYYhEMd3qu/80DwQk9KjsNSxE3fXWq0g== + dependencies: + object-hash "^1.1.2" + stack-trace "0.0.9" + uuid "^3.0.0" + +serve-static@~1.10.2: + version "1.10.3" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.10.3.tgz#ce5a6ecd3101fed5ec09827dac22a9c29bfb0535" + integrity sha1-zlpuzTEB/tXsCYJ9rCKpwpv7BTU= + dependencies: + escape-html "~1.0.3" + parseurl "~1.3.1" + send "0.13.2" + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shelljs@0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.2.tgz#345b7df7763f4c2340d584abb532c5f752ca9e35" + integrity sha512-pRXeNrCA2Wd9itwhvLp5LZQvPJ0wU6bcjaTMywHHGX5XWhVN2nzSu7WV0q+oUY7mGK3mgSkDDzP3MgjqdyIgbQ== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + +sift@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/sift/-/sift-7.0.1.tgz#47d62c50b159d316f1372f8b53f9c10cd21a4b08" + integrity sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g== + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.6" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.6.tgz#24e630c4b0f03fea446a2bd299e62b4a6ca8d0af" + integrity sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ== + +sliced@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" + integrity sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E= + +slug@0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/slug/-/slug-0.9.1.tgz#af08f608a7c11516b61778aa800dce84c518cfda" + integrity sha1-rwj2CKfBFRa2F3iqgA3OhMUYz9o= + dependencies: + unicode ">= 0.3.1" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +sntp@1.x.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" + integrity sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg= + dependencies: + hoek "2.x.x" + +sntp@2.x.x: + version "2.1.0" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.1.0.tgz#2c6cec14fedc2222739caf9b5c3d85d1cc5a2cc8" + integrity sha512-FL1b58BDrqS3A11lJ0zEdnJ3UOKqVxawAkF3k7F0CVN7VQ34aZrV+G8BZ1WC9ZL7NyrwsW0oviwsWDgRuVYtJg== + dependencies: + hoek "4.x.x" + +source-map-resolve@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-url@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== + +source-map@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" + integrity sha1-66T12pwNyZneaAMti092FzZSA2s= + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.6, source-map@~0.5.1: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sparse-bitfield@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" + integrity sha1-/0rm5oZWBWuks+eSqzM004JzyhE= + dependencies: + memory-pager "^1.0.2" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +srcset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/srcset/-/srcset-1.0.0.tgz#a5669de12b42f3b1d5e83ed03c71046fc48f41ef" + integrity sha1-pWad4StC87HV6D7QPHEEb8SPQe8= + dependencies: + array-uniq "^1.0.2" + number-is-nan "^1.0.0" + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +stack-trace@0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.9.tgz#a8f6eaeca90674c333e7c43953f275b451510695" + integrity sha1-qPbq7KkGdMMz58Q5U/J1tFFRBpU= + +stack-trace@~0.0.9: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +statuses@1, "statuses@>= 1.2.1 < 2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +statuses@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.2.1.tgz#dded45cc18256d51ed40aec142489d5c61026d28" + integrity sha1-3e1FzBglbVHtQK7BQkidXGECbSg= + +stream-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stream-length/-/stream-length-1.0.2.tgz#8277f3cbee49a4daabcfdb4e2f4a9b5e9f2c9f00" + integrity sha1-gnfzy+5JpNqrz9tOL0qbXp8snwA= + dependencies: + bluebird "^2.6.2" + +strftime@~0.10.0: + version "0.10.1" + resolved "https://registry.yarnpkg.com/strftime/-/strftime-0.10.1.tgz#108af1176a7d5252cfbddbdb2af044dfae538389" + integrity sha512-nVvH6JG8KlXFPC0f8lojLgEsPA18lRpLZ+RrJh/NkQV2tqOgZfbas8gcU8SFgnnqR3rWzZPYu6N2A3xzs/8rQg== + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.0.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +stringstream@~0.0.4: + version "0.0.6" + resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.6.tgz#7880225b0d4ad10e30927d167a1d6f2fd3b33a72" + integrity sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA== + +strip-ansi@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-json-comments@2.0.1, strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +symbol@~0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/symbol/-/symbol-0.3.1.tgz#b6f9a900d496a57f02408f22198c109dda063041" + integrity sha1-tvmpANSWpX8CQI8iGYwQndoGMEE= + +term-size@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" + integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk= + dependencies: + execa "^0.7.0" + +timed-out@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" + integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +topo@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" + integrity sha1-6ddRYV0buH3IZdsYL6HKCl71NtU= + dependencies: + hoek "2.x.x" + +touch@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" + integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA== + dependencies: + nopt "~1.0.10" + +tough-cookie@~2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.2.2.tgz#c83a1830f4e5ef0b93ef2a3488e724f8de016ac7" + integrity sha1-yDoYMPTl7wuT7yo0iOck+N4Basc= + +tough-cookie@~2.3.3: + version "2.3.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.4.tgz#ec60cee38ac675063ffc97a5c18970578ee83655" + integrity sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA== + dependencies: + punycode "^1.4.1" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tunnel-agent@~0.4.1: + version "0.4.3" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" + integrity sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us= + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-is@~1.6.11, type-is@~1.6.6: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +uglify-js@^2.6: + version "2.8.29" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" + integrity sha1-KcVzMUgFe7Th913zW3qcty5qWd0= + dependencies: + source-map "~0.5.1" + yargs "~3.10.0" + optionalDependencies: + uglify-to-browserify "~1.0.0" + +uglify-to-browserify@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" + integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc= + +uid-safe@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.0.0.tgz#a7f3c6ca64a1f6a5d04ec0ef3e4c3d5367317137" + integrity sha1-p/PGymSh9qXQTsDvPkw9U2cxcTc= + dependencies: + base64-url "1.2.1" + +undefsafe@^2.0.2: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +underscore@1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" + integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI= + +underscore@~1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209" + integrity sha1-a7rwh3UA02vjTsqlhODbn+8DUgk= + +"unicode@>= 0.3.1": + version "13.0.0" + resolved "https://registry.yarnpkg.com/unicode/-/unicode-13.0.0.tgz#0775fe86cdbb1fa30e8d060afe194f71aa0c5306" + integrity sha512-osNPLT4Lqna/sV6DQikrB8m4WxR61/k0fnhfKnkPGcZImczW3IysRXvWxfdqGUjh0Ju2o/tGGgu46mlfc/cpZw== + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +unique-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" + integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo= + dependencies: + crypto-random-string "^1.0.0" + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +unzip-response@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" + integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= + +upath@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" + integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== + +update-notifier@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" + integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw== + dependencies: + boxen "^1.2.1" + chalk "^2.0.1" + configstore "^3.0.0" + import-lazy "^2.1.0" + is-ci "^1.0.10" + is-installed-globally "^0.1.0" + is-npm "^1.0.0" + latest-version "^3.0.0" + semver-diff "^2.0.0" + xdg-basedir "^3.0.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +url-parse-lax@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" + integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM= + dependencies: + prepend-http "^1.0.1" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +utcstring@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/utcstring/-/utcstring-0.1.0.tgz#430fd510ab7fc95b5d5910c902d79880c208436b" + integrity sha1-Qw/VEKt/yVtdWRDJAteYgMIIQ2s= + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +utils-merge@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" + integrity sha1-ApT7kiu5N1FTVBxPcJYjHyh8ivg= + +uuid@3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14" + integrity sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA== + +uuid@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +uuid@^3.0.0, uuid@^3.1.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +uvm@1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/uvm/-/uvm-1.7.3.tgz#57b37b218a158fa5c059de8527cd67ab64d82663" + integrity sha512-aKnLDcsr/qSYyiF9p049Kqatk/tHxT/gNanpbDzmdQ+XYo0E8lkCYwf478daiu8rXE3+TznBB8Sw/TKakJ6H1A== + dependencies: + circular-json "0.3.1" + inherits "2.0.3" + lodash "4.17.10" + uuid "3.2.1" + +vary@^1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +vary@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.0.1.tgz#99e4981566a286118dfb2b817357df7993376d10" + integrity sha1-meSYFWaihhGN+yuBc1ffeZM3bRA= + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +widest-line@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc" + integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA== + dependencies: + string-width "^2.1.1" + +window-size@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" + integrity sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0= + +word-wrap@1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + integrity sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8= + +wordwrap@~0.0.2: + version "0.0.3" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" + integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@^2.0.0: + version "2.4.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481" + integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ== + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + signal-exit "^3.0.2" + +xdg-basedir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" + integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= + +xmlbuilder@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-10.0.0.tgz#c64e52f8ae097fe5fd46d1c38adaade071ee1b55" + integrity sha512-7RWHlmF1yU/E++BZkRQTEv8ZFAhZ+YHINUAxiZ5LQTKRQq//igpiY8rh7dJqPzgb/IzeC5jH9P7OaCERfM9DwA== + +xtend@^4.0.0, xtend@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +yargs@~3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" + integrity sha1-9+572FfdfB0tOMDnTvvWgdFDH9E= + dependencies: + camelcase "^1.0.2" + cliui "^2.1.0" + decamelize "^1.0.0" + window-size "0.1.0" diff --git a/.framework/node/charts/.helmignore b/.framework/node/charts/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/.framework/node/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/node/charts/Chart.yaml b/.framework/node/charts/Chart.yaml new file mode 100644 index 0000000..b2beb19 --- /dev/null +++ b/.framework/node/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/node/charts/templates/_helpers.yml b/.framework/node/charts/templates/_helpers.yml new file mode 100644 index 0000000..49515f2 --- /dev/null +++ b/.framework/node/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/node/charts/templates/anythink-backend-deployment.yaml b/.framework/node/charts/templates/anythink-backend-deployment.yaml new file mode 100644 index 0000000..260d252 --- /dev/null +++ b/.framework/node/charts/templates/anythink-backend-deployment.yaml @@ -0,0 +1,55 @@ +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 + - "yarn seeds && yarn start" + env: + - name: MONGODB_URI + value: "{{ .Values.database.connectionProtocol }}{{ .Values.database.serviceName }}:{{ .Values.database.servicePort }}/{{ .Values.database.databaseName }}" + - name: NODE_ENV + value: development + - name: PORT + value: "{{ .Values.backend.containerPort }}" + 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 }} + restartPolicy: Always diff --git a/.framework/node/charts/templates/anythink-backend-service.yaml b/.framework/node/charts/templates/anythink-backend-service.yaml new file mode 100644 index 0000000..21bb516 --- /dev/null +++ b/.framework/node/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/node/charts/templates/anythink-frontend-deployment.yaml b/.framework/node/charts/templates/anythink-frontend-deployment.yaml new file mode 100644 index 0000000..f9be249 --- /dev/null +++ b/.framework/node/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/node/charts/templates/anythink-frontend-service.yaml b/.framework/node/charts/templates/anythink-frontend-service.yaml new file mode 100644 index 0000000..217f8c5 --- /dev/null +++ b/.framework/node/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/node/charts/templates/database-deployment.yaml b/.framework/node/charts/templates/database-deployment.yaml new file mode 100644 index 0000000..62deccc --- /dev/null +++ b/.framework/node/charts/templates/database-deployment.yaml @@ -0,0 +1,35 @@ +{{- 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 }} + 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/node/charts/templates/database-pvc.yaml b/.framework/node/charts/templates/database-pvc.yaml new file mode 100644 index 0000000..88517f3 --- /dev/null +++ b/.framework/node/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/node/charts/templates/database-service.yaml b/.framework/node/charts/templates/database-service.yaml new file mode 100644 index 0000000..80b47d3 --- /dev/null +++ b/.framework/node/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/node/charts/values.yaml b/.framework/node/charts/values.yaml new file mode 100644 index 0000000..1fa785d --- /dev/null +++ b/.framework/node/charts/values.yaml @@ -0,0 +1,68 @@ +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 + serviceName: mongodb-node + containerPort: 27017 + servicePort: 27017 + connectionProtocol: mongodb:// + databaseName: anythink-market + replicaCount: 1 + service: + type: ClusterIP + port: 80 + image: + repository: mongo + pullPolicy: IfNotPresent + tag: "latest" + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi diff --git a/.framework/node/docker-compose.yml b/.framework/node/docker-compose.yml new file mode 100644 index 0000000..c233da8 --- /dev/null +++ b/.framework/node/docker-compose.yml @@ -0,0 +1,58 @@ +version: "3.8" +services: + anythink-backend-node: + build: ./backend + container_name: anythink-backend-node + command: sh -c "cd backend && yarn install && /wait-for-it.sh mongodb-node:27017 -q -t 60 && yarn dev" + + environment: + - NODE_ENV=development + - PORT=3000 + - MONGODB_URI=mongodb://mongodb-node:27017/anythink-market + - 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: + - "mongodb-node" + + anythink-frontend-react: + build: ./frontend + container_name: anythink-frontend-react + command: sh -c "cd frontend && yarn install && /wait-for-it.sh anythink-backend-node:3000 -t 120 --strict -- curl --head -X GET --retry 30 --retry-connrefused --retry-delay 1 anythink-backend-node: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-node" + + mongodb-node: + container_name: mongodb-node + restart: always + image: mongo + logging: + driver: none + volumes: + - ~/mongo/data:/data/db + ports: + - '27017:27017' + + anythink-ack: + image: public.ecr.aws/v0a2l7y2/wilco/anythink-ack:latest + container_name: anythink-ack + environment: + - GITHUB_TOKEN=$GITHUB_TOKEN + - CODESPACE_NAME=$CODESPACE_NAME + depends_on: + - "anythink-frontend-react" diff --git a/.framework/python/.devcontainer/devcontainer.json b/.framework/python/.devcontainer/devcontainer.json new file mode 100644 index 0000000..171e2d2 --- /dev/null +++ b/.framework/python/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Anythink Development Container", + "image": "public.ecr.aws/v0a2l7y2/wilco/anythink-devcontainer:latest" +} diff --git a/.framework/python/backend/.dockerignore b/.framework/python/backend/.dockerignore new file mode 100644 index 0000000..238473f --- /dev/null +++ b/.framework/python/backend/.dockerignore @@ -0,0 +1,20 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.env* +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +*.log +.git* +scripts +postman +./postgres-data diff --git a/.framework/python/backend/.gitignore b/.framework/python/backend/.gitignore new file mode 100644 index 0000000..ab61e76 --- /dev/null +++ b/.framework/python/backend/.gitignore @@ -0,0 +1,110 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.idea/ +.vscode/ + +# Project +postgres-data diff --git a/.framework/python/backend/Dockerfile b/.framework/python/backend/Dockerfile new file mode 100644 index 0000000..7dfd3f3 --- /dev/null +++ b/.framework/python/backend/Dockerfile @@ -0,0 +1,2 @@ +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-backend-python:latest + diff --git a/.framework/python/backend/Dockerfile.aws b/.framework/python/backend/Dockerfile.aws new file mode 100644 index 0000000..3ee3974 --- /dev/null +++ b/.framework/python/backend/Dockerfile.aws @@ -0,0 +1,15 @@ +FROM python:3.9.13 + +ENV VIRTUAL_ENV=/opt/venv +RUN python3 -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +RUN pip install poetry==1.2.0 + +# Pre-install poetry packages +WORKDIR /usr/src +COPY backend ./backend +COPY .wilco ./.wilco +WORKDIR /usr/src/backend +RUN poetry install +RUN poetry export -f "requirements.txt" --without-hashes --with-credentials > "requirements.txt" diff --git a/.framework/python/backend/LICENSE b/.framework/python/backend/LICENSE new file mode 100644 index 0000000..173d6d0 --- /dev/null +++ b/.framework/python/backend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Nik Sidnev + +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 +SELLERS 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. \ No newline at end of file diff --git a/.framework/python/backend/README.md b/.framework/python/backend/README.md new file mode 100644 index 0000000..3a6fc73 --- /dev/null +++ b/.framework/python/backend/README.md @@ -0,0 +1,30 @@ +Web routes +========== + +All routes are available on `/docs` or `/redoc` paths with Swagger or ReDoc. + +Project structure +================= + +Files related to application are in the `app` or `tests` directories. Application parts are: + + app + ├── api - web related stuff. + │   ├── dependencies - dependencies for routes definition. + │   ├── errors - definition of error handlers. + │   └── routes - web routes. + ├── core - application configuration, startup events, logging. + ├── db - db related stuff. + │   ├── migrations - manually written alembic migrations. + │   └── repositories - all crud stuff. + ├── models - pydantic models for this application. + │   ├── domain - main models that are used almost everywhere. + │   └── schemas - schemas for using in web routes. + ├── resources - strings that are used in web responses. + ├── services - logic that is not just crud related. + └── main.py - FastAPI application creation and configuration. + +Project structure +================= + +Project dependencies are managed by poetry (https://python-poetry.org), using venv (https://docs.python.org/3/library/venv.html). diff --git a/.framework/python/backend/alembic.ini b/.framework/python/backend/alembic.ini new file mode 100644 index 0000000..2c43e60 --- /dev/null +++ b/.framework/python/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = ./app/db/migrations + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/.framework/python/backend/app/__init__.py b/.framework/python/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/api/__init__.py b/.framework/python/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/api/dependencies/__init__.py b/.framework/python/backend/app/api/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/api/dependencies/authentication.py b/.framework/python/backend/app/api/dependencies/authentication.py new file mode 100644 index 0000000..dbc8d48 --- /dev/null +++ b/.framework/python/backend/app/api/dependencies/authentication.py @@ -0,0 +1,111 @@ +# noqa:WPS201 +from typing import Callable, Optional + +from fastapi import Depends, HTTPException, Security +from fastapi.security import APIKeyHeader +from starlette import requests, status +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.api.dependencies.database import get_repository +from app.core.config import get_app_settings +from app.core.settings.app import AppSettings +from app.db.errors import EntityDoesNotExist +from app.db.repositories.users import UsersRepository +from app.models.domain.users import User +from app.resources import strings +from app.services import jwt + +HEADER_KEY = "Authorization" + + +class RWAPIKeyHeader(APIKeyHeader): + async def __call__( # noqa: WPS610 + self, + request: requests.Request, + ) -> Optional[str]: + try: + return await super().__call__(request) + except StarletteHTTPException as original_auth_exc: + raise HTTPException( + status_code=original_auth_exc.status_code, + detail=strings.AUTHENTICATION_REQUIRED, + ) + + +def get_current_user_authorizer(*, required: bool = True) -> Callable: # type: ignore + return _get_current_user if required else _get_current_user_optional + + +def _get_authorization_header_retriever( + *, + required: bool = True, +) -> Callable: # type: ignore + return _get_authorization_header if required else _get_authorization_header_optional + + +def _get_authorization_header( + api_key: str = Security(RWAPIKeyHeader(name=HEADER_KEY)), + settings: AppSettings = Depends(get_app_settings), +) -> str: + try: + token_prefix, token = api_key.split(" ") + except ValueError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=strings.WRONG_TOKEN_PREFIX, + ) + if token_prefix != settings.jwt_token_prefix: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=strings.WRONG_TOKEN_PREFIX, + ) + + return token + + +def _get_authorization_header_optional( + authorization: Optional[str] = Security( + RWAPIKeyHeader(name=HEADER_KEY, auto_error=False), + ), + settings: AppSettings = Depends(get_app_settings), +) -> str: + if authorization: + return _get_authorization_header(authorization, settings) + + return "" + + +async def _get_current_user( + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + token: str = Depends(_get_authorization_header_retriever()), + settings: AppSettings = Depends(get_app_settings), +) -> User: + try: + username = jwt.get_username_from_token( + token, + str(settings.secret_key.get_secret_value()), + ) + except ValueError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=strings.MALFORMED_PAYLOAD, + ) + + try: + return await users_repo.get_user_by_username(username=username) + except EntityDoesNotExist: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=strings.MALFORMED_PAYLOAD, + ) + + +async def _get_current_user_optional( + repo: UsersRepository = Depends(get_repository(UsersRepository)), + token: str = Depends(_get_authorization_header_retriever(required=False)), + settings: AppSettings = Depends(get_app_settings), +) -> Optional[User]: + if token: + return await _get_current_user(repo, token, settings) + + return None diff --git a/.framework/python/backend/app/api/dependencies/comments.py b/.framework/python/backend/app/api/dependencies/comments.py new file mode 100644 index 0000000..07073b7 --- /dev/null +++ b/.framework/python/backend/app/api/dependencies/comments.py @@ -0,0 +1,37 @@ +from typing import Optional + +from fastapi import Depends, HTTPException, Path +from starlette import status + +from app.api.dependencies import items, authentication, database +from app.db.errors import EntityDoesNotExist +from app.db.repositories.comments import CommentsRepository +from app.models.domain.items import Item +from app.models.domain.comments import Comment +from app.models.domain.users import User +from app.resources import strings + + +async def get_comment_by_id_from_path( + comment_id: int = Path(..., ge=1), + item: Item = Depends(items.get_item_by_slug_from_path), + user: Optional[User] = Depends( + authentication.get_current_user_authorizer(required=False), + ), + comments_repo: CommentsRepository = Depends( + database.get_repository(CommentsRepository), + ), +) -> Comment: + try: + return await comments_repo.get_comment_by_id( + comment_id=comment_id, + item=item, + user=user, + ) + except EntityDoesNotExist: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=strings.COMMENT_DOES_NOT_EXIST, + ) + + diff --git a/.framework/python/backend/app/api/dependencies/database.py b/.framework/python/backend/app/api/dependencies/database.py new file mode 100644 index 0000000..cc91306 --- /dev/null +++ b/.framework/python/backend/app/api/dependencies/database.py @@ -0,0 +1,30 @@ +from typing import AsyncGenerator, Callable, Type + +from asyncpg.connection import Connection +from asyncpg.pool import Pool +from fastapi import Depends +from starlette.requests import Request + +from app.db.repositories.base import BaseRepository + + +def _get_db_pool(request: Request) -> Pool: + return request.app.state.pool + + +async def _get_connection_from_pool( + pool: Pool = Depends(_get_db_pool), +) -> AsyncGenerator[Connection, None]: + async with pool.acquire() as conn: + yield conn + + +def get_repository( + repo_type: Type[BaseRepository], +) -> Callable[[Connection], BaseRepository]: + def _get_repo( + conn: Connection = Depends(_get_connection_from_pool), + ) -> BaseRepository: + return repo_type(conn) + + return _get_repo diff --git a/.framework/python/backend/app/api/dependencies/items.py b/.framework/python/backend/app/api/dependencies/items.py new file mode 100644 index 0000000..8688c96 --- /dev/null +++ b/.framework/python/backend/app/api/dependencies/items.py @@ -0,0 +1,59 @@ +from typing import Optional + +from fastapi import Depends, HTTPException, Path, Query +from starlette import status + +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.database import get_repository +from app.db.errors import EntityDoesNotExist +from app.db.repositories.items import ItemsRepository +from app.models.domain.items import Item +from app.models.domain.users import User +from app.models.schemas.items import ( + DEFAULT_ITEMS_LIMIT, + DEFAULT_ITEMS_OFFSET, + ItemsFilters, +) +from app.resources import strings +from app.services.items import check_user_can_modify_item + + +def get_items_filters( + tag: Optional[str] = None, + seller: Optional[str] = None, + favorited: Optional[str] = None, + limit: int = Query(DEFAULT_ITEMS_LIMIT, ge=1), + offset: int = Query(DEFAULT_ITEMS_OFFSET, ge=0), +) -> ItemsFilters: + return ItemsFilters( + tag=tag, + seller=seller, + favorited=favorited, + limit=limit, + offset=offset, + ) + + +async def get_item_by_slug_from_path( + slug: str = Path(..., min_length=1), + user: Optional[User] = Depends(get_current_user_authorizer(required=False)), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> Item: + try: + return await items_repo.get_item_by_slug(slug=slug, requested_user=user) + except EntityDoesNotExist: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=strings.ITEM_DOES_NOT_EXIST_ERROR, + ) + + +def check_item_modification_permissions( + current_item: Item = Depends(get_item_by_slug_from_path), + user: User = Depends(get_current_user_authorizer()), +) -> None: + if not check_user_can_modify_item(current_item, user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=strings.USER_IS_NOT_SELLER_OF_ITEM, + ) diff --git a/.framework/python/backend/app/api/dependencies/profiles.py b/.framework/python/backend/app/api/dependencies/profiles.py new file mode 100644 index 0000000..db8f9b0 --- /dev/null +++ b/.framework/python/backend/app/api/dependencies/profiles.py @@ -0,0 +1,29 @@ +from typing import Optional + +from fastapi import Depends, HTTPException, Path +from starlette.status import HTTP_404_NOT_FOUND + +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.database import get_repository +from app.db.errors import EntityDoesNotExist +from app.db.repositories.profiles import ProfilesRepository +from app.models.domain.profiles import Profile +from app.models.domain.users import User +from app.resources import strings + + +async def get_profile_by_username_from_path( + username: str = Path(..., min_length=1), + user: Optional[User] = Depends(get_current_user_authorizer(required=False)), + profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)), +) -> Profile: + try: + return await profiles_repo.get_profile_by_username( + username=username, + requested_user=user, + ) + except EntityDoesNotExist: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=strings.USER_DOES_NOT_EXIST_ERROR, + ) diff --git a/.framework/python/backend/app/api/errors/__init__.py b/.framework/python/backend/app/api/errors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/api/errors/http_error.py b/.framework/python/backend/app/api/errors/http_error.py new file mode 100644 index 0000000..c503229 --- /dev/null +++ b/.framework/python/backend/app/api/errors/http_error.py @@ -0,0 +1,7 @@ +from fastapi import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse + + +async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse({"errors": [exc.detail]}, status_code=exc.status_code) diff --git a/.framework/python/backend/app/api/errors/validation_error.py b/.framework/python/backend/app/api/errors/validation_error.py new file mode 100644 index 0000000..a85730c --- /dev/null +++ b/.framework/python/backend/app/api/errors/validation_error.py @@ -0,0 +1,28 @@ +from typing import Union + +from fastapi.exceptions import RequestValidationError +from fastapi.openapi.constants import REF_PREFIX +from fastapi.openapi.utils import validation_error_response_definition +from pydantic import ValidationError +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY + + +async def http422_error_handler( + _: Request, + exc: Union[RequestValidationError, ValidationError], +) -> JSONResponse: + return JSONResponse( + {"errors": exc.errors()}, + status_code=HTTP_422_UNPROCESSABLE_ENTITY, + ) + + +validation_error_response_definition["properties"] = { + "errors": { + "title": "Errors", + "type": "array", + "items": {"$ref": "{0}ValidationError".format(REF_PREFIX)}, + }, +} diff --git a/.framework/python/backend/app/api/routes/__init__.py b/.framework/python/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/api/routes/api.py b/.framework/python/backend/app/api/routes/api.py new file mode 100644 index 0000000..7404d51 --- /dev/null +++ b/.framework/python/backend/app/api/routes/api.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from app.api.routes import authentication, comments, profiles, tags, users, ping +from app.api.routes.items import api as items + +router = APIRouter() +router.include_router(ping.router, prefix="/ping") +router.include_router(authentication.router, tags=["authentication"], prefix="/users") +router.include_router(users.router, tags=["users"], prefix="/user") +router.include_router(profiles.router, tags=["profiles"], prefix="/profiles") +router.include_router(items.router, tags=["items"]) +router.include_router( + comments.router, + tags=["comments"], + prefix="/items/{slug}/comments", +) +router.include_router(tags.router, tags=["tags"], prefix="/tags") diff --git a/.framework/python/backend/app/api/routes/authentication.py b/.framework/python/backend/app/api/routes/authentication.py new file mode 100644 index 0000000..35d7322 --- /dev/null +++ b/.framework/python/backend/app/api/routes/authentication.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Body, Depends, HTTPException +from starlette.status import HTTP_201_CREATED, HTTP_400_BAD_REQUEST + +from app.api.dependencies.database import get_repository +from app.core.config import get_app_settings +from app.core.settings.app import AppSettings +from app.db.errors import EntityDoesNotExist +from app.db.repositories.users import UsersRepository +from app.models.schemas.users import ( + UserInCreate, + UserInLogin, + UserInResponse, + UserWithToken, +) +from app.resources import strings +from app.services import jwt +from app.services.authentication import check_email_is_taken, check_username_is_taken +from app.services.event import send_event + +router = APIRouter() + + +@router.post("/login", response_model=UserInResponse, name="auth:login") +async def login( + user_login: UserInLogin = Body(..., embed=True, alias="user"), + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + settings: AppSettings = Depends(get_app_settings), +) -> UserInResponse: + wrong_login_error = HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.INCORRECT_LOGIN_INPUT, + ) + + try: + user = await users_repo.get_user_by_email(email=user_login.email) + except EntityDoesNotExist as existence_error: + raise wrong_login_error from existence_error + + if not user.check_password(user_login.password): + raise wrong_login_error + + token = jwt.create_access_token_for_user( + user, + str(settings.secret_key.get_secret_value()), + ) + return UserInResponse( + user=UserWithToken( + username=user.username, + email=user.email, + bio=user.bio, + image=user.image, + token=token, + ), + ) + + +@router.post( + "", + status_code=HTTP_201_CREATED, + response_model=UserInResponse, + name="auth:register", +) +async def register( + user_create: UserInCreate = Body(..., embed=True, alias="user"), + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + settings: AppSettings = Depends(get_app_settings), +) -> UserInResponse: + if await check_username_is_taken(users_repo, user_create.username): + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.USERNAME_TAKEN, + ) + + if await check_email_is_taken(users_repo, user_create.email): + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.EMAIL_TAKEN, + ) + + user = await users_repo.create_user(**user_create.dict()) + + token = jwt.create_access_token_for_user( + user, + str(settings.secret_key.get_secret_value()), + ) + + send_event('user_created', { 'username': user.username }) + + return UserInResponse( + user=UserWithToken( + username=user.username, + email=user.email, + bio=user.bio, + image=user.image, + token=token, + ), + ) diff --git a/.framework/python/backend/app/api/routes/comments.py b/.framework/python/backend/app/api/routes/comments.py new file mode 100644 index 0000000..5810e3a --- /dev/null +++ b/.framework/python/backend/app/api/routes/comments.py @@ -0,0 +1,67 @@ +from typing import Optional + +from fastapi import APIRouter, Body, Depends, Response +from starlette import status + +from app.api.dependencies.items import get_item_by_slug_from_path +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.comments import get_comment_by_id_from_path +from app.api.dependencies.database import get_repository +from app.db.repositories.comments import CommentsRepository +from app.models.domain.items import Item +from app.models.domain.comments import Comment +from app.models.domain.users import User +from app.models.schemas.comments import ( + CommentInCreate, + CommentInResponse, + ListOfCommentsInResponse, +) + +router = APIRouter() + + +@router.get( + "", + response_model=ListOfCommentsInResponse, + name="comments:get-comments-for-item", +) +async def list_comments_for_item( + item: Item = Depends(get_item_by_slug_from_path), + user: Optional[User] = Depends(get_current_user_authorizer(required=False)), + comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)), +) -> ListOfCommentsInResponse: + comments = await comments_repo.get_comments_for_item(item=item, user=user) + return ListOfCommentsInResponse(comments=comments) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=CommentInResponse, + name="comments:create-comment-for-item", +) +async def create_comment_for_item( + comment_create: CommentInCreate = Body(..., embed=True, alias="comment"), + item: Item = Depends(get_item_by_slug_from_path), + user: User = Depends(get_current_user_authorizer()), + comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)), +) -> CommentInResponse: + comment = await comments_repo.create_comment_for_item( + body=comment_create.body, + item=item, + user=user, + ) + return CommentInResponse(comment=comment) + + +@router.delete( + "/{comment_id}", + status_code=status.HTTP_204_NO_CONTENT, + name="comments:delete-comment-from-item", + response_class=Response, +) +async def delete_comment_from_item( + comment: Comment = Depends(get_comment_by_id_from_path), + comments_repo: CommentsRepository = Depends(get_repository(CommentsRepository)), +) -> None: + await comments_repo.delete_comment(comment=comment) diff --git a/.framework/python/backend/app/api/routes/home.py b/.framework/python/backend/app/api/routes/home.py new file mode 100644 index 0000000..5521b49 --- /dev/null +++ b/.framework/python/backend/app/api/routes/home.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/", status_code=200) +async def home(): + return "Anythink backend is up." + +@router.get("/health", status_code=200) +async def health(): + return "OK" diff --git a/.framework/python/backend/app/api/routes/items/__init__.py b/.framework/python/backend/app/api/routes/items/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/api/routes/items/api.py b/.framework/python/backend/app/api/routes/items/api.py new file mode 100644 index 0000000..731c4e0 --- /dev/null +++ b/.framework/python/backend/app/api/routes/items/api.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.api.routes.items import items_common, items_resource + +router = APIRouter() + +router.include_router(items_common.router, prefix="/items") +router.include_router(items_resource.router, prefix="/items") diff --git a/.framework/python/backend/app/api/routes/items/items_common.py b/.framework/python/backend/app/api/routes/items/items_common.py new file mode 100644 index 0000000..64447b4 --- /dev/null +++ b/.framework/python/backend/app/api/routes/items/items_common.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from starlette import status + +from app.api.dependencies.items import get_item_by_slug_from_path +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.database import get_repository +from app.db.repositories.items import ItemsRepository +from app.models.domain.items import Item +from app.models.domain.users import User +from app.models.schemas.items import ( + DEFAULT_ITEMS_LIMIT, + DEFAULT_ITEMS_OFFSET, + ItemForResponse, + ItemInResponse, + ListOfItemsInResponse, +) +from app.resources import strings + +router = APIRouter() + + +@router.get( + "/feed", + response_model=ListOfItemsInResponse, + name="items:get-user-feed-items", +) +async def get_items_for_user_feed( + limit: int = Query(DEFAULT_ITEMS_LIMIT, ge=1), + offset: int = Query(DEFAULT_ITEMS_OFFSET, ge=0), + user: User = Depends(get_current_user_authorizer()), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> ListOfItemsInResponse: + items = await items_repo.get_items_for_user_feed( + user=user, + limit=limit, + offset=offset, + ) + items_for_response = [ + ItemForResponse(**item.dict()) for item in items + ] + return ListOfItemsInResponse( + items=items_for_response, + items_count=len(items), + ) + + +@router.post( + "/{slug}/favorite", + response_model=ItemInResponse, + name="items:mark-item-favorite", +) +async def mark_item_as_favorite( + item: Item = Depends(get_item_by_slug_from_path), + user: User = Depends(get_current_user_authorizer()), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> ItemInResponse: + if not item.favorited: + await items_repo.add_item_into_favorites(item=item, user=user) + + return ItemInResponse( + item=ItemForResponse.from_orm( + item.copy( + update={ + "favorited": True, + "favorites_count": item.favorites_count + 1, + }, + ), + ), + ) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=strings.ITEM_IS_ALREADY_FAVORITED, + ) + + +@router.delete( + "/{slug}/favorite", + response_model=ItemInResponse, + name="items:unmark-item-favorite", +) +async def remove_item_from_favorites( + item: Item = Depends(get_item_by_slug_from_path), + user: User = Depends(get_current_user_authorizer()), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> ItemInResponse: + if item.favorited: + await items_repo.remove_item_from_favorites(item=item, user=user) + + return ItemInResponse( + item=ItemForResponse.from_orm( + item.copy( + update={ + "favorited": False, + "favorites_count": item.favorites_count - 1, + }, + ), + ), + ) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=strings.ITEM_IS_NOT_FAVORITED, + ) diff --git a/.framework/python/backend/app/api/routes/items/items_resource.py b/.framework/python/backend/app/api/routes/items/items_resource.py new file mode 100644 index 0000000..e396091 --- /dev/null +++ b/.framework/python/backend/app/api/routes/items/items_resource.py @@ -0,0 +1,122 @@ +from typing import Optional + +from fastapi import APIRouter, Body, Depends, HTTPException, Response +from starlette import status + +from app.api.dependencies.items import ( + check_item_modification_permissions, + get_item_by_slug_from_path, + get_items_filters, +) +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.database import get_repository +from app.db.repositories.items import ItemsRepository +from app.models.domain.items import Item +from app.models.domain.users import User +from app.models.schemas.items import ( + ItemForResponse, + ItemInCreate, + ItemInResponse, + ItemInUpdate, + ItemsFilters, + ListOfItemsInResponse, +) +from app.resources import strings +from app.services.items import check_item_exists, get_slug_for_item +from app.services.event import send_event + +router = APIRouter() + + +@router.get("", response_model=ListOfItemsInResponse, name="items:list-items") +async def list_items( + items_filters: ItemsFilters = Depends(get_items_filters), + user: Optional[User] = Depends(get_current_user_authorizer(required=False)), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> ListOfItemsInResponse: + items = await items_repo.filter_items( + tag=items_filters.tag, + seller=items_filters.seller, + favorited=items_filters.favorited, + limit=items_filters.limit, + offset=items_filters.offset, + requested_user=user, + ) + items_for_response = [ + ItemForResponse.from_orm(item) for item in items + ] + return ListOfItemsInResponse( + items=items_for_response, + items_count=len(items), + ) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=ItemInResponse, + name="items:create-item", +) +async def create_new_item( + item_create: ItemInCreate = Body(..., embed=True, alias="item"), + user: User = Depends(get_current_user_authorizer()), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> ItemInResponse: + slug = get_slug_for_item(item_create.title) + if await check_item_exists(items_repo, slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=strings.ITEM_ALREADY_EXISTS, + ) + item = await items_repo.create_item( + slug=slug, + title=item_create.title, + description=item_create.description, + body=item_create.body, + seller=user, + tags=item_create.tags, + image=item_create.image + ) + send_event('item_created', {'item': item_create.title}) + return ItemInResponse(item=ItemForResponse.from_orm(item)) + + +@router.get("/{slug}", response_model=ItemInResponse, name="items:get-item") +async def retrieve_item_by_slug( + item: Item = Depends(get_item_by_slug_from_path), +) -> ItemInResponse: + return ItemInResponse(item=ItemForResponse.from_orm(item)) + + +@router.put( + "/{slug}", + response_model=ItemInResponse, + name="items:update-item", + dependencies=[Depends(check_item_modification_permissions)], +) +async def update_item_by_slug( + item_update: ItemInUpdate = Body(..., embed=True, alias="item"), + current_item: Item = Depends(get_item_by_slug_from_path), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> ItemInResponse: + slug = get_slug_for_item(item_update.title) if item_update.title else None + item = await items_repo.update_item( + item=current_item, + slug=slug, + **item_update.dict(), + ) + return ItemInResponse(item=ItemForResponse.from_orm(item)) + + +@router.delete( + "/{slug}", + status_code=status.HTTP_204_NO_CONTENT, + name="items:delete-item", + dependencies=[Depends(check_item_modification_permissions)], + response_class=Response, +) +async def delete_item_by_slug( + item: Item = Depends(get_item_by_slug_from_path), + items_repo: ItemsRepository = Depends(get_repository(ItemsRepository)), +) -> None: + await items_repo.delete_item(item=item) diff --git a/.framework/python/backend/app/api/routes/ping.py b/.framework/python/backend/app/api/routes/ping.py new file mode 100644 index 0000000..3d0f5f0 --- /dev/null +++ b/.framework/python/backend/app/api/routes/ping.py @@ -0,0 +1,17 @@ +import json +import logging + +from fastapi import APIRouter, Depends, HTTPException +from app.services.event import send_event + +router = APIRouter() + +@router.get("") +async def check_ping(): + try: + res = send_event('ping', {}) + return res.json() + + except Exception as e: + logging.error(e) + raise HTTPException(status_code=500, detail="Error") diff --git a/.framework/python/backend/app/api/routes/profiles.py b/.framework/python/backend/app/api/routes/profiles.py new file mode 100644 index 0000000..6ec1bf0 --- /dev/null +++ b/.framework/python/backend/app/api/routes/profiles.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, HTTPException +from starlette.status import HTTP_400_BAD_REQUEST + +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.database import get_repository +from app.api.dependencies.profiles import get_profile_by_username_from_path +from app.db.repositories.profiles import ProfilesRepository +from app.models.domain.profiles import Profile +from app.models.domain.users import User +from app.models.schemas.profiles import ProfileInResponse +from app.resources import strings + +router = APIRouter() + + +@router.get( + "/{username}", + response_model=ProfileInResponse, + name="profiles:get-profile", +) +async def retrieve_profile_by_username( + profile: Profile = Depends(get_profile_by_username_from_path), +) -> ProfileInResponse: + return ProfileInResponse(profile=profile) + + +@router.post( + "/{username}/follow", + response_model=ProfileInResponse, + name="profiles:follow-user", +) +async def follow_for_user( + profile: Profile = Depends(get_profile_by_username_from_path), + user: User = Depends(get_current_user_authorizer()), + profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)), +) -> ProfileInResponse: + if user.username == profile.username: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.UNABLE_TO_FOLLOW_YOURSELF, + ) + + if profile.following: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.USER_IS_ALREADY_FOLLOWED, + ) + + await profiles_repo.add_user_into_followers( + target_user=profile, + requested_user=user, + ) + + return ProfileInResponse(profile=profile.copy(update={"following": True})) + + +@router.delete( + "/{username}/follow", + response_model=ProfileInResponse, + name="profiles:unsubscribe-from-user", +) +async def unsubscribe_from_user( + profile: Profile = Depends(get_profile_by_username_from_path), + user: User = Depends(get_current_user_authorizer()), + profiles_repo: ProfilesRepository = Depends(get_repository(ProfilesRepository)), +) -> ProfileInResponse: + if user.username == profile.username: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.UNABLE_TO_UNSUBSCRIBE_FROM_YOURSELF, + ) + + if not profile.following: + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.USER_IS_NOT_FOLLOWED, + ) + + await profiles_repo.remove_user_from_followers( + target_user=profile, + requested_user=user, + ) + + return ProfileInResponse(profile=profile.copy(update={"following": False})) diff --git a/.framework/python/backend/app/api/routes/tags.py b/.framework/python/backend/app/api/routes/tags.py new file mode 100644 index 0000000..4706187 --- /dev/null +++ b/.framework/python/backend/app/api/routes/tags.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, Depends + +from app.api.dependencies.database import get_repository +from app.db.repositories.tags import TagsRepository +from app.models.schemas.tags import TagsInList + +router = APIRouter() + + +@router.get("", response_model=TagsInList, name="tags:get-all") +async def get_all_tags( + tags_repo: TagsRepository = Depends(get_repository(TagsRepository)), +) -> TagsInList: + tags = await tags_repo.get_all_tags() + return TagsInList(tags=tags) diff --git a/.framework/python/backend/app/api/routes/users.py b/.framework/python/backend/app/api/routes/users.py new file mode 100644 index 0000000..81bcf97 --- /dev/null +++ b/.framework/python/backend/app/api/routes/users.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Body, Depends, HTTPException +from starlette.status import HTTP_400_BAD_REQUEST + +from app.api.dependencies.authentication import get_current_user_authorizer +from app.api.dependencies.database import get_repository +from app.core.config import get_app_settings +from app.core.settings.app import AppSettings +from app.db.repositories.users import UsersRepository +from app.models.domain.users import User +from app.models.schemas.users import UserInResponse, UserInUpdate, UserWithToken +from app.resources import strings +from app.services import jwt +from app.services.authentication import check_email_is_taken, check_username_is_taken + +router = APIRouter() + + +@router.get("", response_model=UserInResponse, name="users:get-current-user") +async def retrieve_current_user( + user: User = Depends(get_current_user_authorizer()), + settings: AppSettings = Depends(get_app_settings), +) -> UserInResponse: + token = jwt.create_access_token_for_user( + user, + str(settings.secret_key.get_secret_value()), + ) + return UserInResponse( + user=UserWithToken( + username=user.username, + email=user.email, + bio=user.bio, + image=user.image, + token=token, + ), + ) + + +@router.put("", response_model=UserInResponse, name="users:update-current-user") +async def update_current_user( + user_update: UserInUpdate = Body(..., embed=True, alias="user"), + current_user: User = Depends(get_current_user_authorizer()), + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + settings: AppSettings = Depends(get_app_settings), +) -> UserInResponse: + if user_update.username and user_update.username != current_user.username: + if await check_username_is_taken(users_repo, user_update.username): + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.USERNAME_TAKEN, + ) + + if user_update.email and user_update.email != current_user.email: + if await check_email_is_taken(users_repo, user_update.email): + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail=strings.EMAIL_TAKEN, + ) + + user = await users_repo.update_user(user=current_user, **user_update.dict()) + + token = jwt.create_access_token_for_user( + user, + str(settings.secret_key.get_secret_value()), + ) + return UserInResponse( + user=UserWithToken( + username=user.username, + email=user.email, + bio=user.bio, + image=user.image, + token=token, + ), + ) diff --git a/.framework/python/backend/app/core/__init__.py b/.framework/python/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/core/config.py b/.framework/python/backend/app/core/config.py new file mode 100644 index 0000000..87f58a8 --- /dev/null +++ b/.framework/python/backend/app/core/config.py @@ -0,0 +1,21 @@ +from functools import lru_cache +from typing import Dict, Type + +from app.core.settings.app import AppSettings +from app.core.settings.base import AppEnvTypes, BaseAppSettings +from app.core.settings.development import DevAppSettings +from app.core.settings.production import ProdAppSettings +from app.core.settings.test import TestAppSettings + +environments: Dict[AppEnvTypes, Type[AppSettings]] = { + AppEnvTypes.dev: DevAppSettings, + AppEnvTypes.prod: ProdAppSettings, + AppEnvTypes.test: TestAppSettings, +} + + +@lru_cache +def get_app_settings() -> AppSettings: + app_env = BaseAppSettings().app_env + config = environments[app_env] + return config() diff --git a/.framework/python/backend/app/core/events.py b/.framework/python/backend/app/core/events.py new file mode 100644 index 0000000..3e82ee3 --- /dev/null +++ b/.framework/python/backend/app/core/events.py @@ -0,0 +1,25 @@ +from typing import Callable + +from fastapi import FastAPI +from loguru import logger + +from app.core.settings.app import AppSettings +from app.db.events import close_db_connection, connect_to_db + + +def create_start_app_handler( + app: FastAPI, + settings: AppSettings, +) -> Callable: # type: ignore + async def start_app() -> None: + await connect_to_db(app, settings) + + return start_app + + +def create_stop_app_handler(app: FastAPI) -> Callable: # type: ignore + @logger.catch + async def stop_app() -> None: + await close_db_connection(app) + + return stop_app diff --git a/.framework/python/backend/app/core/logging.py b/.framework/python/backend/app/core/logging.py new file mode 100644 index 0000000..10ceda7 --- /dev/null +++ b/.framework/python/backend/app/core/logging.py @@ -0,0 +1,25 @@ +import logging +from types import FrameType +from typing import cast + +from loguru import logger + + +class InterceptHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: # pragma: no cover + # Get corresponding Loguru level if it exists + try: + level = logger.level(record.levelname).name + except ValueError: + level = str(record.levelno) + + # Find caller from where originated the logged message + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: # noqa: WPS609 + frame = cast(FrameType, frame.f_back) + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, + record.getMessage(), + ) diff --git a/.framework/python/backend/app/core/settings/__init__.py b/.framework/python/backend/app/core/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/core/settings/app.py b/.framework/python/backend/app/core/settings/app.py new file mode 100644 index 0000000..fc0c606 --- /dev/null +++ b/.framework/python/backend/app/core/settings/app.py @@ -0,0 +1,57 @@ +import logging +import sys +from typing import Any, Dict, List, Tuple + +from loguru import logger +from pydantic import PostgresDsn, SecretStr + +from app.core.logging import InterceptHandler +from app.core.settings.base import BaseAppSettings + + +class AppSettings(BaseAppSettings): + debug: bool = False + docs_url: str = "/docs" + openapi_prefix: str = "" + openapi_url: str = "/openapi.json" + redoc_url: str = "/redoc" + title: str = "FastAPI example application" + version: str = "0.0.0" + + database_url: PostgresDsn + max_connection_count: int = 5 + min_connection_count: int = 5 + + secret_key: SecretStr = SecretStr("secret") + + api_prefix: str = "/api" + + jwt_token_prefix: str = "Token" + + allowed_hosts: List[str] = ["*"] + + logging_level: int = logging.INFO + loggers: Tuple[str, str] = ("uvicorn.asgi", "uvicorn.access") + + class Config: + validate_assignment = True + + @property + def fastapi_kwargs(self) -> Dict[str, Any]: + return { + "debug": self.debug, + "docs_url": self.docs_url, + "openapi_prefix": self.openapi_prefix, + "openapi_url": self.openapi_url, + "redoc_url": self.redoc_url, + "title": self.title, + "version": self.version, + } + + def configure_logging(self) -> None: + logging.getLogger().handlers = [InterceptHandler()] + for logger_name in self.loggers: + logging_logger = logging.getLogger(logger_name) + logging_logger.handlers = [InterceptHandler(level=self.logging_level)] + + logger.configure(handlers=[{"sink": sys.stderr, "level": self.logging_level}]) diff --git a/.framework/python/backend/app/core/settings/base.py b/.framework/python/backend/app/core/settings/base.py new file mode 100644 index 0000000..0397cbb --- /dev/null +++ b/.framework/python/backend/app/core/settings/base.py @@ -0,0 +1,16 @@ +from enum import Enum + +from pydantic import BaseSettings + + +class AppEnvTypes(Enum): + prod: str = "prod" + dev: str = "dev" + test: str = "test" + + +class BaseAppSettings(BaseSettings): + app_env: AppEnvTypes = AppEnvTypes.prod + + class Config: + env_file = ".env" diff --git a/.framework/python/backend/app/core/settings/development.py b/.framework/python/backend/app/core/settings/development.py new file mode 100644 index 0000000..041a77d --- /dev/null +++ b/.framework/python/backend/app/core/settings/development.py @@ -0,0 +1,14 @@ +import logging + +from app.core.settings.app import AppSettings + + +class DevAppSettings(AppSettings): + debug: bool = True + + title: str = "Dev FastAPI example application" + + logging_level: int = logging.DEBUG + + class Config(AppSettings.Config): + env_file = ".env" diff --git a/.framework/python/backend/app/core/settings/production.py b/.framework/python/backend/app/core/settings/production.py new file mode 100644 index 0000000..f2d3eab --- /dev/null +++ b/.framework/python/backend/app/core/settings/production.py @@ -0,0 +1,6 @@ +from app.core.settings.app import AppSettings + + +class ProdAppSettings(AppSettings): + class Config(AppSettings.Config): + env_file = "prod.env" diff --git a/.framework/python/backend/app/core/settings/test.py b/.framework/python/backend/app/core/settings/test.py new file mode 100644 index 0000000..bea3c53 --- /dev/null +++ b/.framework/python/backend/app/core/settings/test.py @@ -0,0 +1,19 @@ +import logging + +from pydantic import PostgresDsn, SecretStr + +from app.core.settings.app import AppSettings + + +class TestAppSettings(AppSettings): + debug: bool = True + + title: str = "Test FastAPI example application" + + secret_key: SecretStr = SecretStr("test_secret") + + database_url: PostgresDsn + max_connection_count: int = 5 + min_connection_count: int = 5 + + logging_level: int = logging.DEBUG diff --git a/.framework/python/backend/app/db/__init__.py b/.framework/python/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/db/errors.py b/.framework/python/backend/app/db/errors.py new file mode 100644 index 0000000..bb3ef66 --- /dev/null +++ b/.framework/python/backend/app/db/errors.py @@ -0,0 +1,2 @@ +class EntityDoesNotExist(Exception): + """Raised when entity was not found in database.""" diff --git a/.framework/python/backend/app/db/events.py b/.framework/python/backend/app/db/events.py new file mode 100644 index 0000000..5c42303 --- /dev/null +++ b/.framework/python/backend/app/db/events.py @@ -0,0 +1,29 @@ +import asyncpg +from fastapi import FastAPI +from loguru import logger + +from app.core.settings.app import AppSettings + + +async def connect_to_db(app: FastAPI, settings: AppSettings) -> None: + logger.info("Connecting to PostgreSQL") + + # SQLAlchemy >= 1.4 deprecated the use of `postgres://` in favor of `postgresql://` + # for the database connection url + database_url = settings.database_url.replace("postgres://", "postgresql://") + + app.state.pool = await asyncpg.create_pool( + str(database_url), + min_size=settings.min_connection_count, + max_size=settings.max_connection_count, + ) + + logger.info("Connection established") + + +async def close_db_connection(app: FastAPI) -> None: + logger.info("Closing connection to database") + + await app.state.pool.close() + + logger.info("Connection closed") diff --git a/.framework/python/backend/app/db/migrations/env.py b/.framework/python/backend/app/db/migrations/env.py new file mode 100644 index 0000000..93122b2 --- /dev/null +++ b/.framework/python/backend/app/db/migrations/env.py @@ -0,0 +1,41 @@ +import pathlib +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +sys.path.append(str(pathlib.Path(__file__).resolve().parents[3])) + +from app.core.config import get_app_settings # isort:skip + +SETTINGS = get_app_settings() + +# SQLAlchemy >= 1.4 deprecated the use of `postgres://` in favor of `postgresql://` +# for the database connection url +DATABASE_URL = SETTINGS.database_url.replace("postgres://", "postgresql://") + +config = context.config + +fileConfig(config.config_file_name) # type: ignore + +target_metadata = None + +config.set_main_option("sqlalchemy.url", str(DATABASE_URL)) + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +run_migrations_online() diff --git a/.framework/python/backend/app/db/migrations/script.py.mako b/.framework/python/backend/app/db/migrations/script.py.mako new file mode 100644 index 0000000..3217cf0 --- /dev/null +++ b/.framework/python/backend/app/db/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/.framework/python/backend/app/db/migrations/versions/fdf8821871d7_main_tables.py b/.framework/python/backend/app/db/migrations/versions/fdf8821871d7_main_tables.py new file mode 100644 index 0000000..04f1036 --- /dev/null +++ b/.framework/python/backend/app/db/migrations/versions/fdf8821871d7_main_tables.py @@ -0,0 +1,217 @@ +"""main tables + +Revision ID: fdf8821871d7 +Revises: +Create Date: 2019-09-22 01:36:44.791880 + +""" +from typing import Tuple + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import func + +revision = "fdf8821871d7" +down_revision = None +branch_labels = None +depends_on = None + + +def create_updated_at_trigger() -> None: + op.execute( + """ + CREATE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS + $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; + $$ language 'plpgsql'; + """ + ) + + +def timestamps() -> Tuple[sa.Column, sa.Column]: + return ( + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now(), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.current_timestamp(), + ), + ) + + +def create_users_table() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("username", sa.Text, unique=True, nullable=False, index=True), + sa.Column("email", sa.Text, unique=True, nullable=False, index=True), + sa.Column("salt", sa.Text, nullable=False), + sa.Column("hashed_password", sa.Text), + sa.Column("bio", sa.Text, nullable=False, server_default=""), + sa.Column("image", sa.Text), + *timestamps(), + ) + op.execute( + """ + CREATE TRIGGER update_user_modtime + BEFORE UPDATE + ON users + FOR EACH ROW + EXECUTE PROCEDURE update_updated_at_column(); + """ + ) + + +def create_followers_to_followings_table() -> None: + op.create_table( + "followers_to_followings", + sa.Column( + "follower_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "following_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + ) + op.create_primary_key( + "pk_followers_to_followings", + "followers_to_followings", + ["follower_id", "following_id"], + ) + + +def create_items_table() -> None: + op.create_table( + "items", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("slug", sa.Text, unique=True, nullable=False, index=True), + sa.Column("title", sa.Text, nullable=False), + sa.Column("description", sa.Text, nullable=False), + sa.Column("body", sa.Text, nullable=True), + sa.Column("image", sa.Text, nullable=True), + sa.Column( + "seller_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL") + ), + *timestamps(), + ) + op.execute( + """ + CREATE TRIGGER update_item_modtime + BEFORE UPDATE + ON items + FOR EACH ROW + EXECUTE PROCEDURE update_updated_at_column(); + """ + ) + + +def create_tags_table() -> None: + op.create_table("tags", sa.Column("tag", sa.Text, primary_key=True)) + + +def create_items_to_tags_table() -> None: + op.create_table( + "items_to_tags", + sa.Column( + "item_id", + sa.Integer, + sa.ForeignKey("items.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "tag", + sa.Text, + sa.ForeignKey("tags.tag", ondelete="CASCADE"), + nullable=False, + ), + ) + op.create_primary_key( + "pk_items_to_tags", "items_to_tags", ["item_id", "tag"] + ) + + +def create_favorites_table() -> None: + op.create_table( + "favorites", + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "item_id", + sa.Integer, + sa.ForeignKey("items.id", ondelete="CASCADE"), + nullable=False, + ), + ) + op.create_primary_key("pk_favorites", "favorites", ["user_id", "item_id"]) + + +def create_comments_table() -> None: + op.create_table( + "comments", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("body", sa.Text, nullable=False), + sa.Column( + "seller_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "item_id", + sa.Integer, + sa.ForeignKey("items.id", ondelete="CASCADE"), + nullable=False, + ), + *timestamps(), + ) + op.execute( + """ + CREATE TRIGGER update_comment_modtime + BEFORE UPDATE + ON comments + FOR EACH ROW + EXECUTE PROCEDURE update_updated_at_column(); + """ + ) + + +def upgrade() -> None: + create_updated_at_trigger() + create_users_table() + create_followers_to_followings_table() + create_items_table() + create_tags_table() + create_items_to_tags_table() + create_favorites_table() + create_comments_table() + + +def downgrade() -> None: + op.drop_table("comments") + op.drop_table("favorites") + op.drop_table("items_to_tags") + op.drop_table("tags") + op.drop_table("items") + op.drop_table("followers_to_followings") + op.drop_table("users") + op.execute("DROP FUNCTION update_updated_at_column") diff --git a/.framework/python/backend/app/db/queries/__init__.py b/.framework/python/backend/app/db/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/db/queries/queries.py b/.framework/python/backend/app/db/queries/queries.py new file mode 100644 index 0000000..a190595 --- /dev/null +++ b/.framework/python/backend/app/db/queries/queries.py @@ -0,0 +1,5 @@ +import pathlib + +import aiosql + +queries = aiosql.from_path(pathlib.Path(__file__).parent / "sql", "asyncpg") diff --git a/.framework/python/backend/app/db/queries/queries.pyi b/.framework/python/backend/app/db/queries/queries.pyi new file mode 100644 index 0000000..5f20e85 --- /dev/null +++ b/.framework/python/backend/app/db/queries/queries.pyi @@ -0,0 +1,125 @@ +"""Typings for queries generated by aiosql""" + +from typing import Dict, Optional, Sequence + +from asyncpg import Connection, Record + +class TagsQueriesMixin: + async def get_all_tags(self, conn: Connection) -> Record: ... + async def create_new_tags( + self, conn: Connection, tags: Sequence[Dict[str, str]] + ) -> None: ... + +class UsersQueriesMixin: + async def get_user_by_email(self, conn: Connection, *, email: str) -> Record: ... + async def get_user_by_username( + self, conn: Connection, *, username: str + ) -> Record: ... + async def create_new_user( + self, + conn: Connection, + *, + username: str, + email: str, + salt: str, + hashed_password: str + ) -> Record: ... + async def update_user_by_username( + self, + conn: Connection, + *, + username: str, + new_username: str, + new_email: str, + new_salt: str, + new_password: str, + new_bio: Optional[str], + new_image: Optional[str] + ) -> Record: ... + +class ProfilesQueriesMixin: + async def is_user_following_for_another( + self, conn: Connection, *, follower_username: str, following_username: str + ) -> Record: ... + async def subscribe_user_to_another( + self, conn: Connection, *, follower_username: str, following_username: str + ) -> None: ... + async def unsubscribe_user_from_another( + self, conn: Connection, *, follower_username: str, following_username: str + ) -> None: ... + +class CommentsQueriesMixin: + async def get_comments_for_item_by_slug( + self, conn: Connection, *, slug: str + ) -> Record: ... + async def get_comment_by_id_and_slug( + self, conn: Connection, *, comment_id: int, item_slug: str + ) -> Record: ... + async def create_new_comment( + self, conn: Connection, *, body: str, item_slug: str, seller_username: str + ) -> Record: ... + async def delete_comment_by_id( + self, conn: Connection, *, comment_id: int, seller_username: str + ) -> None: ... + +class ItemsQueriesMixin: + async def add_item_to_favorites( + self, conn: Connection, *, username: str, slug: str + ) -> None: ... + async def remove_item_from_favorites( + self, conn: Connection, *, username: str, slug: str + ) -> None: ... + async def is_item_in_favorites( + self, conn: Connection, *, username: str, slug: str + ) -> Record: ... + async def get_favorites_count_for_item( + self, conn: Connection, *, slug: str + ) -> Record: ... + async def get_tags_for_item_by_slug( + self, conn: Connection, *, slug: str + ) -> Record: ... + async def get_item_by_slug(self, conn: Connection, *, slug: str) -> Record: ... + async def create_new_item( + self, + conn: Connection, + *, + slug: str, + title: str, + description: str, + body: str, + seller_username: str, + image: str + ) -> Record: ... + async def add_tags_to_item( + self, conn: Connection, tags_slugs: Sequence[Dict[str, str]] + ) -> None: ... + async def delete_tags_from_item( + self, conn: Connection, *, slug: str + ) -> None: ... + async def update_item( + self, + conn: Connection, + *, + slug: str, + seller_username: str, + new_title: str, + new_body: str, + new_description: str, + new_image: str + ) -> Record: ... + async def delete_item( + self, conn: Connection, *, slug: str, seller_username: str + ) -> None: ... + async def get_items_for_feed( + self, conn: Connection, *, follower_username: str, limit: int, offset: int + ) -> Record: ... + +class Queries( + TagsQueriesMixin, + UsersQueriesMixin, + ProfilesQueriesMixin, + CommentsQueriesMixin, + ItemsQueriesMixin, +): ... + +queries: Queries diff --git a/.framework/python/backend/app/db/queries/sql/comments.sql b/.framework/python/backend/app/db/queries/sql/comments.sql new file mode 100644 index 0000000..7a342ec --- /dev/null +++ b/.framework/python/backend/app/db/queries/sql/comments.sql @@ -0,0 +1,41 @@ +-- name: get-comments-for-item-by-slug +SELECT c.id, + c.body, + c.created_at, + c.updated_at, + (SELECT username FROM users WHERE id = c.seller_id) as seller_username +FROM comments c + INNER JOIN items a ON c.item_id = a.id AND (a.slug = :slug) +ORDER BY c.created_at DESC; + +-- name: get-comment-by-id-and-slug^ +SELECT c.id, + c.body, + c.created_at, + c.updated_at, + (SELECT username FROM users WHERE id = c.seller_id) as seller_username +FROM comments c + INNER JOIN items a ON c.item_id = a.id AND (a.slug = :item_slug) +WHERE c.id = :comment_id; + +-- name: create-new-comment 0 THEN TRUE ELSE FALSE END AS favorited +FROM favorites +WHERE user_id = (SELECT id FROM users WHERE username = :username) + AND item_id = (SELECT id FROM items WHERE slug = :slug); + + +-- name: get-favorites-count-for-item^ +SELECT count(*) as favorites_count +FROM favorites +WHERE item_id = (SELECT id FROM items WHERE slug = :slug); + + +-- name: get-tags-for-item-by-slug +SELECT t.tag +FROM tags t + INNER JOIN items_to_tags att ON + t.tag = att.tag + AND + att.item_id = (SELECT id FROM items WHERE slug = :slug); + + +-- name: get-item-by-slug^ +SELECT id, + slug, + title, + description, + body, + image, + created_at, + updated_at, + (SELECT username FROM users WHERE id = seller_id) AS seller_username +FROM items +WHERE slug = :slug +LIMIT 1; + + +-- name: create-new-item None: + super().__init__("${0}".format(count)) + + +class TypedTable(Table): + __table__ = "" + + def __init__( + self, + name: Optional[str] = None, + schema: Optional[str] = None, + alias: Optional[str] = None, + query_cls: Optional[Query] = None, + ) -> None: + if name is None: + if self.__table__: + name = self.__table__ + else: + name = self.__class__.__name__ + + super().__init__(name, schema, alias, query_cls) + + +class Users(TypedTable): + __table__ = "users" + + id: int + username: str + + +class Items(TypedTable): + __table__ = "items" + + id: int + slug: str + title: str + description: str + body: str + seller_id: int + created_at: datetime + updated_at: datetime + + +class Tags(TypedTable): + __table__ = "tags" + + tag: str + + +class ItemsToTags(TypedTable): + __table__ = "items_to_tags" + + item_id: int + tag: str + + +class Favorites(TypedTable): + __table__ = "favorites" + + item_id: int + user_id: int + + +users = Users() +items = Items() +tags = Tags() +items_to_tags = ItemsToTags() +favorites = Favorites() diff --git a/.framework/python/backend/app/db/repositories/__init__.py b/.framework/python/backend/app/db/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/db/repositories/base.py b/.framework/python/backend/app/db/repositories/base.py new file mode 100644 index 0000000..8f8a5c3 --- /dev/null +++ b/.framework/python/backend/app/db/repositories/base.py @@ -0,0 +1,10 @@ +from asyncpg.connection import Connection + + +class BaseRepository: + def __init__(self, conn: Connection) -> None: + self._conn = conn + + @property + def connection(self) -> Connection: + return self._conn diff --git a/.framework/python/backend/app/db/repositories/comments.py b/.framework/python/backend/app/db/repositories/comments.py new file mode 100644 index 0000000..cb432b3 --- /dev/null +++ b/.framework/python/backend/app/db/repositories/comments.py @@ -0,0 +1,103 @@ +from typing import List, Optional + +from asyncpg import Connection, Record + +from app.db.errors import EntityDoesNotExist +from app.db.queries.queries import queries +from app.db.repositories.base import BaseRepository +from app.db.repositories.profiles import ProfilesRepository +from app.models.domain.items import Item +from app.models.domain.comments import Comment +from app.models.domain.users import User + + +class CommentsRepository(BaseRepository): + def __init__(self, conn: Connection) -> None: + super().__init__(conn) + self._profiles_repo = ProfilesRepository(conn) + + async def get_comment_by_id( + self, + *, + comment_id: int, + item: Item, + user: Optional[User] = None, + ) -> Comment: + comment_row = await queries.get_comment_by_id_and_slug( + self.connection, + comment_id=comment_id, + item_slug=item.slug, + ) + if comment_row: + return await self._get_comment_from_db_record( + comment_row=comment_row, + seller_username=comment_row["seller_username"], + requested_user=user, + ) + + raise EntityDoesNotExist( + "comment with id {0} does not exist".format(comment_id), + ) + + async def get_comments_for_item( + self, + *, + item: Item, + user: Optional[User] = None, + ) -> List[Comment]: + comments_rows = await queries.get_comments_for_item_by_slug( + self.connection, + slug=item.slug, + ) + return [ + await self._get_comment_from_db_record( + comment_row=comment_row, + seller_username=comment_row["seller_username"], + requested_user=user, + ) + for comment_row in comments_rows + ] + + async def create_comment_for_item( + self, + *, + body: str, + item: Item, + user: User, + ) -> Comment: + comment_row = await queries.create_new_comment( + self.connection, + body=body, + item_slug=item.slug, + seller_username=user.username, + ) + return await self._get_comment_from_db_record( + comment_row=comment_row, + seller_username=comment_row["seller_username"], + requested_user=user, + ) + + async def delete_comment(self, *, comment: Comment) -> None: + await queries.delete_comment_by_id( + self.connection, + comment_id=comment.id_, + seller_username=comment.seller.username, + ) + + async def _get_comment_from_db_record( + self, + *, + comment_row: Record, + seller_username: str, + requested_user: Optional[User], + ) -> Comment: + return Comment( + id_=comment_row["id"], + body=comment_row["body"], + seller=await self._profiles_repo.get_profile_by_username( + username=seller_username, + requested_user=requested_user, + ), + created_at=comment_row["created_at"], + updated_at=comment_row["updated_at"], + ) diff --git a/.framework/python/backend/app/db/repositories/items.py b/.framework/python/backend/app/db/repositories/items.py new file mode 100644 index 0000000..8ff146a --- /dev/null +++ b/.framework/python/backend/app/db/repositories/items.py @@ -0,0 +1,353 @@ +from typing import List, Optional, Sequence, Union + +from asyncpg import Connection, Record +from pypika import Query, Order + +from app.db.errors import EntityDoesNotExist +from app.db.queries.queries import queries +from app.db.queries.tables import ( + Parameter, + items, + items_to_tags, + favorites, + tags as tags_table, + users, +) +from app.db.repositories.base import BaseRepository +from app.db.repositories.profiles import ProfilesRepository +from app.db.repositories.tags import TagsRepository +from app.models.domain.items import Item +from app.models.domain.users import User + +SELLER_USERNAME_ALIAS = "seller_username" +SLUG_ALIAS = "slug" + +CAMEL_OR_SNAKE_CASE_TO_WORDS = r"^[a-z\d_\-]+|[A-Z\d_\-][^A-Z\d_\-]*" + + +class ItemsRepository(BaseRepository): # noqa: WPS214 + def __init__(self, conn: Connection) -> None: + super().__init__(conn) + self._profiles_repo = ProfilesRepository(conn) + self._tags_repo = TagsRepository(conn) + + async def create_item( # noqa: WPS211 + self, + *, + slug: str, + title: str, + description: str, + seller: User, + body: Optional[str] = None, + image: Optional[str] = None, + tags: Optional[Sequence[str]] = None, + ) -> Item: + async with self.connection.transaction(): + item_row = await queries.create_new_item( + self.connection, + slug=slug, + title=title, + description=description, + body=body, + seller_username=seller.username, + image=image + ) + + if tags: + await self._tags_repo.create_tags_that_dont_exist(tags=tags) + await self._link_item_with_tags(slug=slug, tags=tags) + + return await self._get_item_from_db_record( + item_row=item_row, + slug=slug, + seller_username=item_row[SELLER_USERNAME_ALIAS], + requested_user=seller, + ) + + async def update_item( # noqa: WPS211 + self, + *, + item: Item, + slug: Optional[str] = None, + title: Optional[str] = None, + body: Optional[str] = None, + description: Optional[str] = None, + image: Optional[str] = None, + tags: Optional[Sequence[str]] = None, + ) -> Item: + updated_item = item.copy(deep=True) + updated_item.title = title or item.title + updated_item.body = body or item.body + updated_item.description = description or item.description + updated_item.image = image or item.image + + async with self.connection.transaction(): + updated_item.updated_at = await queries.update_item( + self.connection, + slug=item.slug, + seller_username=item.seller.username, + new_title=updated_item.title, + new_body=updated_item.body, + new_description=updated_item.description, + new_image=updated_item.image, + ) + + if tags: + await self._tags_repo.create_tags_that_dont_exist(tags=tags) + await self._unlink_item_from_tags(slug=item.slug) + await self._link_item_with_tags(slug=item.slug, tags=tags) + + return await self.get_item_by_slug( + slug=item.slug, + requested_user=item.seller, + ) + + async def delete_item(self, *, item: Item) -> None: + async with self.connection.transaction(): + await queries.delete_item( + self.connection, + slug=item.slug, + seller_username=item.seller.username, + ) + + async def filter_items( # noqa: WPS211 + self, + *, + tag: Optional[str] = None, + seller: Optional[str] = None, + favorited: Optional[str] = None, + limit: int = 20, + offset: int = 0, + requested_user: Optional[User] = None, + ) -> List[Item]: + query_params: List[Union[str, int]] = [] + query_params_count = 0 + + # fmt: off + query = Query.from_( + items, + ).select( + items.id, + items.slug, + items.title, + items.description, + items.body, + items.image, + items.created_at, + items.updated_at, + Query.from_( + users, + ).where( + users.id == items.seller_id, + ).select( + users.username, + ).as_( + SELLER_USERNAME_ALIAS, + ), + ).orderby( + items.created_at, order=Order.desc, + ) + # fmt: on + + if tag: + query_params.append(tag) + query_params_count += 1 + + # fmt: off + query = query.join( + items_to_tags, + ).on( + (items.id == items_to_tags.item_id) & ( + items_to_tags.tag == Query.from_( + tags_table, + ).where( + tags_table.tag == Parameter(query_params_count), + ).select( + tags_table.tag, + ) + ), + ) + # fmt: on + + if seller: + query_params.append(seller) + query_params_count += 1 + + # fmt: off + query = query.join( + users, + ).on( + (items.seller_id == users.id) & ( + users.id == Query.from_( + users, + ).where( + users.username == Parameter(query_params_count), + ).select( + users.id, + ) + ), + ) + # fmt: on + + if favorited: + query_params.append(favorited) + query_params_count += 1 + + # fmt: off + query = query.join( + favorites, + ).on( + (items.id == favorites.item_id) & ( + favorites.user_id == Query.from_( + users, + ).where( + users.username == Parameter(query_params_count), + ).select( + users.id, + ) + ), + ) + # fmt: on + + query = query.limit(Parameter(query_params_count + 1)).offset( + Parameter(query_params_count + 2), + ) + query_params.extend([limit, offset]) + + items_rows = await self.connection.fetch(query.get_sql(), *query_params) + + return [ + await self.get_item_by_slug(slug=item_row['slug'], requested_user=requested_user) + for item_row in items_rows + ] + + async def get_items_for_user_feed( + self, + *, + user: User, + limit: int = 20, + offset: int = 0, + ) -> List[Item]: + items_rows = await queries.get_items_for_feed( + self.connection, + follower_username=user.username, + limit=limit, + offset=offset, + ) + return [ + await self._get_item_from_db_record( + item_row=item_row, + slug=item_row[SLUG_ALIAS], + seller_username=item_row[SELLER_USERNAME_ALIAS], + requested_user=user, + ) + for item_row in items_rows + ] + + async def get_item_by_slug( + self, + *, + slug: str, + requested_user: Optional[User] = None, + ) -> Item: + item_row = await queries.get_item_by_slug(self.connection, slug=slug) + if item_row: + return await self._get_item_from_db_record( + item_row=item_row, + slug=item_row[SLUG_ALIAS], + seller_username=item_row[SELLER_USERNAME_ALIAS], + requested_user=requested_user, + ) + + raise EntityDoesNotExist("item with slug {0} does not exist".format(slug)) + + async def get_tags_for_item_by_slug(self, *, slug: str) -> List[str]: + tag_rows = await queries.get_tags_for_item_by_slug( + self.connection, + slug=slug, + ) + return [row["tag"] for row in tag_rows] + + async def get_favorites_count_for_item_by_slug(self, *, slug: str) -> int: + return ( + await queries.get_favorites_count_for_item(self.connection, slug=slug) + )["favorites_count"] + + async def is_item_favorited_by_user(self, *, slug: str, user: User) -> bool: + return ( + await queries.is_item_in_favorites( + self.connection, + username=user.username, + slug=slug, + ) + )["favorited"] + + async def add_item_into_favorites(self, *, item: Item, user: User) -> None: + await queries.add_item_to_favorites( + self.connection, + username=user.username, + slug=item.slug, + ) + + async def remove_item_from_favorites( + self, + *, + item: Item, + user: User, + ) -> None: + await queries.remove_item_from_favorites( + self.connection, + username=user.username, + slug=item.slug, + ) + + async def _get_item_from_db_record( + self, + *, + item_row: Record, + slug: str, + seller_username: str, + requested_user: Optional[User], + ) -> Item: + title_query = Query.from_(items).select(items.title).where(items.slug == slug) + result_rows = await self.connection.fetch(title_query.get_sql()) + if not len(result_rows): + raise Exception(f'No item with slug {slug}') + title = result_rows[0]['title'] + + return Item( + id_=item_row["id"], + slug=slug, + title=title, + description=item_row["description"], + body=item_row["body"], + image=item_row["image"], + seller=await self._profiles_repo.get_profile_by_username( + username=seller_username, + requested_user=requested_user, + ), + tags=await self.get_tags_for_item_by_slug(slug=slug), + favorites_count=await self.get_favorites_count_for_item_by_slug( + slug=slug, + ), + favorited=await self.is_item_favorited_by_user( + slug=slug, + user=requested_user, + ) + if requested_user + else False, + created_at=item_row["created_at"], + updated_at=item_row["updated_at"], + ) + + async def _link_item_with_tags(self, *, slug: str, tags: Sequence[str]) -> None: + await queries.add_tags_to_item( + self.connection, + [{SLUG_ALIAS: slug, "tag": tag} for tag in tags], + ) + + async def _unlink_item_with_tags(self, *, slug: str) -> None: + await queries.delete_tags_from_item( + self.connection, + slug=slug, + ) diff --git a/.framework/python/backend/app/db/repositories/profiles.py b/.framework/python/backend/app/db/repositories/profiles.py new file mode 100644 index 0000000..20d43a0 --- /dev/null +++ b/.framework/python/backend/app/db/repositories/profiles.py @@ -0,0 +1,74 @@ +from typing import Optional, Union + +from asyncpg import Connection + +from app.db.queries.queries import queries +from app.db.repositories.base import BaseRepository +from app.db.repositories.users import UsersRepository +from app.models.domain.profiles import Profile +from app.models.domain.users import User + +UserLike = Union[User, Profile] + + +class ProfilesRepository(BaseRepository): + def __init__(self, conn: Connection): + super().__init__(conn) + self._users_repo = UsersRepository(conn) + + async def get_profile_by_username( + self, + *, + username: str, + requested_user: Optional[UserLike], + ) -> Profile: + user = await self._users_repo.get_user_by_username(username=username) + + profile = Profile(username=user.username, bio=user.bio, image=user.image) + if requested_user: + profile.following = await self.is_user_following_for_another_user( + target_user=user, + requested_user=requested_user, + ) + + return profile + + async def is_user_following_for_another_user( + self, + *, + target_user: UserLike, + requested_user: UserLike, + ) -> bool: + return ( + await queries.is_user_following_for_another( + self.connection, + follower_username=requested_user.username, + following_username=target_user.username, + ) + )["is_following"] + + async def add_user_into_followers( + self, + *, + target_user: UserLike, + requested_user: UserLike, + ) -> None: + async with self.connection.transaction(): + await queries.subscribe_user_to_another( + self.connection, + follower_username=requested_user.username, + following_username=target_user.username, + ) + + async def remove_user_from_followers( + self, + *, + target_user: UserLike, + requested_user: UserLike, + ) -> None: + async with self.connection.transaction(): + await queries.unsubscribe_user_from_another( + self.connection, + follower_username=requested_user.username, + following_username=target_user.username, + ) diff --git a/.framework/python/backend/app/db/repositories/tags.py b/.framework/python/backend/app/db/repositories/tags.py new file mode 100644 index 0000000..5734992 --- /dev/null +++ b/.framework/python/backend/app/db/repositories/tags.py @@ -0,0 +1,13 @@ +from typing import List, Sequence + +from app.db.queries.queries import queries +from app.db.repositories.base import BaseRepository + + +class TagsRepository(BaseRepository): + async def get_all_tags(self) -> List[str]: + tags_row = await queries.get_all_tags(self.connection) + return [tag[0] for tag in tags_row] + + async def create_tags_that_dont_exist(self, *, tags: Sequence[str]) -> None: + await queries.create_new_tags(self.connection, [{"tag": tag} for tag in tags]) diff --git a/.framework/python/backend/app/db/repositories/users.py b/.framework/python/backend/app/db/repositories/users.py new file mode 100644 index 0000000..0bb18ec --- /dev/null +++ b/.framework/python/backend/app/db/repositories/users.py @@ -0,0 +1,81 @@ +from typing import Optional + +from app.db.errors import EntityDoesNotExist +from app.db.queries.queries import queries +from app.db.repositories.base import BaseRepository +from app.models.domain.users import User, UserInDB + + +class UsersRepository(BaseRepository): + async def get_user_by_email(self, *, email: str) -> UserInDB: + user_row = await queries.get_user_by_email(self.connection, email=email) + if user_row: + return UserInDB(**user_row) + + raise EntityDoesNotExist("user with email {0} does not exist".format(email)) + + async def get_user_by_username(self, *, username: str) -> UserInDB: + user_row = await queries.get_user_by_username( + self.connection, + username=username, + ) + if user_row: + return UserInDB(**user_row) + + raise EntityDoesNotExist( + "user with username {0} does not exist".format(username), + ) + + async def create_user( + self, + *, + username: str, + email: str, + password: str, + ) -> UserInDB: + user = UserInDB(username=username, email=email) + user.change_password(password) + + async with self.connection.transaction(): + user_row = await queries.create_new_user( + self.connection, + username=user.username, + email=user.email, + salt=user.salt, + hashed_password=user.hashed_password, + ) + + return user.copy(update=dict(user_row)) + + async def update_user( # noqa: WPS211 + self, + *, + user: User, + username: Optional[str] = None, + email: Optional[str] = None, + password: Optional[str] = None, + bio: Optional[str] = None, + image: Optional[str] = None, + ) -> UserInDB: + user_in_db = await self.get_user_by_username(username=user.username) + + user_in_db.username = username or user_in_db.username + user_in_db.email = email or user_in_db.email + user_in_db.bio = bio or user_in_db.bio + user_in_db.image = image or user_in_db.image + if password: + user_in_db.change_password(password) + + async with self.connection.transaction(): + user_in_db.updated_at = await queries.update_user_by_username( + self.connection, + username=user.username, + new_username=user_in_db.username, + new_email=user_in_db.email, + new_salt=user_in_db.salt, + new_password=user_in_db.hashed_password, + new_bio=user_in_db.bio, + new_image=user_in_db.image, + ) + + return user_in_db diff --git a/.framework/python/backend/app/db/seeds.py b/.framework/python/backend/app/db/seeds.py new file mode 100644 index 0000000..6509e2d --- /dev/null +++ b/.framework/python/backend/app/db/seeds.py @@ -0,0 +1 @@ +print('Please fill the seeds file') diff --git a/.framework/python/backend/app/main.py b/.framework/python/backend/app/main.py new file mode 100644 index 0000000..cff8033 --- /dev/null +++ b/.framework/python/backend/app/main.py @@ -0,0 +1,47 @@ +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException +from starlette.middleware.cors import CORSMiddleware + +from app.api.errors.http_error import http_error_handler +from app.api.errors.validation_error import http422_error_handler +from app.api.routes.api import router as api_router +from app.api.routes.home import router as home_router +from app.core.config import get_app_settings +from app.core.events import create_start_app_handler, create_stop_app_handler + + +def get_application() -> FastAPI: + settings = get_app_settings() + + settings.configure_logging() + + application = FastAPI(**settings.fastapi_kwargs) + + application.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_hosts, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + application.add_event_handler( + "startup", + create_start_app_handler(application, settings), + ) + application.add_event_handler( + "shutdown", + create_stop_app_handler(application), + ) + + application.add_exception_handler(HTTPException, http_error_handler) + application.add_exception_handler(RequestValidationError, http422_error_handler) + + application.include_router(home_router) + application.include_router(api_router, prefix=settings.api_prefix) + + return application + + +app = get_application() diff --git a/.framework/python/backend/app/models/__init__.py b/.framework/python/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/models/common.py b/.framework/python/backend/app/models/common.py new file mode 100644 index 0000000..fdc515b --- /dev/null +++ b/.framework/python/backend/app/models/common.py @@ -0,0 +1,19 @@ +import datetime + +from pydantic import BaseModel, Field, validator + + +class DateTimeModelMixin(BaseModel): + created_at: datetime.datetime = None # type: ignore + updated_at: datetime.datetime = None # type: ignore + + @validator("created_at", "updated_at", pre=True) + def default_datetime( + cls, # noqa: N805 + value: datetime.datetime, # noqa: WPS110 + ) -> datetime.datetime: + return value or datetime.datetime.now() + + +class IDModelMixin(BaseModel): + id_: int = Field(0, alias="id") diff --git a/.framework/python/backend/app/models/domain/__init__.py b/.framework/python/backend/app/models/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/models/domain/comments.py b/.framework/python/backend/app/models/domain/comments.py new file mode 100644 index 0000000..c5ec749 --- /dev/null +++ b/.framework/python/backend/app/models/domain/comments.py @@ -0,0 +1,8 @@ +from app.models.common import DateTimeModelMixin, IDModelMixin +from app.models.domain.profiles import Profile +from app.models.domain.rwmodel import RWModel + + +class Comment(IDModelMixin, DateTimeModelMixin, RWModel): + body: str + seller: Profile diff --git a/.framework/python/backend/app/models/domain/items.py b/.framework/python/backend/app/models/domain/items.py new file mode 100644 index 0000000..3e95353 --- /dev/null +++ b/.framework/python/backend/app/models/domain/items.py @@ -0,0 +1,17 @@ +from typing import List, Optional + +from app.models.common import DateTimeModelMixin, IDModelMixin +from app.models.domain.profiles import Profile +from app.models.domain.rwmodel import RWModel + + +class Item(IDModelMixin, DateTimeModelMixin, RWModel): + slug: str + title: str + description: str + tags: List[str] + seller: Profile + favorited: bool + favorites_count: int + image: Optional[str] + body: Optional[str] diff --git a/.framework/python/backend/app/models/domain/profiles.py b/.framework/python/backend/app/models/domain/profiles.py new file mode 100644 index 0000000..b1e6ac0 --- /dev/null +++ b/.framework/python/backend/app/models/domain/profiles.py @@ -0,0 +1,10 @@ +from typing import Optional + +from app.models.domain.rwmodel import RWModel + + +class Profile(RWModel): + username: str + bio: str = "" + image: Optional[str] = None + following: bool = False diff --git a/.framework/python/backend/app/models/domain/rwmodel.py b/.framework/python/backend/app/models/domain/rwmodel.py new file mode 100644 index 0000000..1c34f3b --- /dev/null +++ b/.framework/python/backend/app/models/domain/rwmodel.py @@ -0,0 +1,21 @@ +import datetime + +from pydantic import BaseConfig, BaseModel + + +def convert_datetime_to_realworld(dt: datetime.datetime) -> str: + return dt.replace(tzinfo=datetime.timezone.utc).isoformat().replace("+00:00", "Z") + + +def convert_field_to_camel_case(string: str) -> str: + return "".join( + word if index == 0 else word.capitalize() + for index, word in enumerate(string.split("_")) + ) + + +class RWModel(BaseModel): + class Config(BaseConfig): + allow_population_by_field_name = True + json_encoders = {datetime.datetime: convert_datetime_to_realworld} + alias_generator = convert_field_to_camel_case diff --git a/.framework/python/backend/app/models/domain/users.py b/.framework/python/backend/app/models/domain/users.py new file mode 100644 index 0000000..3da2f9d --- /dev/null +++ b/.framework/python/backend/app/models/domain/users.py @@ -0,0 +1,24 @@ +from typing import Optional + +from app.models.common import DateTimeModelMixin, IDModelMixin +from app.models.domain.rwmodel import RWModel +from app.services import security + + +class User(RWModel): + username: str + email: str + bio: str = "" + image: Optional[str] = None + + +class UserInDB(IDModelMixin, DateTimeModelMixin, User): + salt: str = "" + hashed_password: str = "" + + def check_password(self, password: str) -> bool: + return security.verify_password(self.salt + password, self.hashed_password) + + def change_password(self, password: str) -> None: + self.salt = security.generate_salt() + self.hashed_password = security.get_password_hash(self.salt + password) diff --git a/.framework/python/backend/app/models/schemas/__init__.py b/.framework/python/backend/app/models/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/models/schemas/comments.py b/.framework/python/backend/app/models/schemas/comments.py new file mode 100644 index 0000000..e230697 --- /dev/null +++ b/.framework/python/backend/app/models/schemas/comments.py @@ -0,0 +1,16 @@ +from typing import List + +from app.models.domain.comments import Comment +from app.models.schemas.rwschema import RWSchema + + +class ListOfCommentsInResponse(RWSchema): + comments: List[Comment] + + +class CommentInResponse(RWSchema): + comment: Comment + + +class CommentInCreate(RWSchema): + body: str diff --git a/.framework/python/backend/app/models/schemas/items.py b/.framework/python/backend/app/models/schemas/items.py new file mode 100644 index 0000000..5c43a59 --- /dev/null +++ b/.framework/python/backend/app/models/schemas/items.py @@ -0,0 +1,46 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.models.domain.items import Item +from app.models.schemas.rwschema import RWSchema + +DEFAULT_ITEMS_LIMIT = 20 +DEFAULT_ITEMS_OFFSET = 0 + + +class ItemForResponse(RWSchema, Item): + tags: List[str] = Field(..., alias="tagList") + + +class ItemInResponse(RWSchema): + item: ItemForResponse + + +class ItemInCreate(RWSchema): + title: str + description: str + body: Optional[str] = None + image: Optional[str] = None + tags: List[str] = Field([], alias="tagList") + + +class ItemInUpdate(RWSchema): + title: Optional[str] = None + description: Optional[str] = None + body: Optional[str] = None + image: Optional[str] = None + tags: Optional[List[str]] = Field(None, alias="tagList") + + +class ListOfItemsInResponse(RWSchema): + items: List[ItemForResponse] + items_count: int + + +class ItemsFilters(BaseModel): + tag: Optional[str] = None + seller: Optional[str] = None + favorited: Optional[str] = None + limit: int = Field(DEFAULT_ITEMS_LIMIT, ge=1) + offset: int = Field(DEFAULT_ITEMS_OFFSET, ge=0) diff --git a/.framework/python/backend/app/models/schemas/jwt.py b/.framework/python/backend/app/models/schemas/jwt.py new file mode 100644 index 0000000..56d1fa3 --- /dev/null +++ b/.framework/python/backend/app/models/schemas/jwt.py @@ -0,0 +1,12 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class JWTMeta(BaseModel): + exp: datetime + sub: str + + +class JWTUser(BaseModel): + username: str diff --git a/.framework/python/backend/app/models/schemas/profiles.py b/.framework/python/backend/app/models/schemas/profiles.py new file mode 100644 index 0000000..5662dfc --- /dev/null +++ b/.framework/python/backend/app/models/schemas/profiles.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from app.models.domain.profiles import Profile + + +class ProfileInResponse(BaseModel): + profile: Profile diff --git a/.framework/python/backend/app/models/schemas/rwschema.py b/.framework/python/backend/app/models/schemas/rwschema.py new file mode 100644 index 0000000..018ad4b --- /dev/null +++ b/.framework/python/backend/app/models/schemas/rwschema.py @@ -0,0 +1,6 @@ +from app.models.domain.rwmodel import RWModel + + +class RWSchema(RWModel): + class Config(RWModel.Config): + orm_mode = True diff --git a/.framework/python/backend/app/models/schemas/tags.py b/.framework/python/backend/app/models/schemas/tags.py new file mode 100644 index 0000000..e9655fb --- /dev/null +++ b/.framework/python/backend/app/models/schemas/tags.py @@ -0,0 +1,7 @@ +from typing import List + +from pydantic import BaseModel + + +class TagsInList(BaseModel): + tags: List[str] diff --git a/.framework/python/backend/app/models/schemas/users.py b/.framework/python/backend/app/models/schemas/users.py new file mode 100644 index 0000000..d0f2bba --- /dev/null +++ b/.framework/python/backend/app/models/schemas/users.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr, HttpUrl + +from app.models.domain.users import User +from app.models.schemas.rwschema import RWSchema + + +class UserInLogin(RWSchema): + email: EmailStr + password: str + + +class UserInCreate(UserInLogin): + username: str + + +class UserInUpdate(BaseModel): + username: Optional[str] = None + email: Optional[EmailStr] = None + password: Optional[str] = None + bio: Optional[str] = None + image: Optional[HttpUrl] = None + + +class UserWithToken(User): + token: str + + +class UserInResponse(RWSchema): + user: UserWithToken diff --git a/.framework/python/backend/app/resources/__init__.py b/.framework/python/backend/app/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/resources/strings.py b/.framework/python/backend/app/resources/strings.py new file mode 100644 index 0000000..d7124dd --- /dev/null +++ b/.framework/python/backend/app/resources/strings.py @@ -0,0 +1,25 @@ +# API messages + +USER_DOES_NOT_EXIST_ERROR = "user does not exist" +ITEM_DOES_NOT_EXIST_ERROR = "item does not exist" +ITEM_ALREADY_EXISTS = "item already exists" +USER_IS_NOT_SELLER_OF_ITEM = "you are not an seller of this item" + +INCORRECT_LOGIN_INPUT = "incorrect email or password" +USERNAME_TAKEN = "user with this username already exists" +EMAIL_TAKEN = "user with this email already exists" + +UNABLE_TO_FOLLOW_YOURSELF = "user can not follow him self" +UNABLE_TO_UNSUBSCRIBE_FROM_YOURSELF = "user can not unsubscribe from him self" +USER_IS_NOT_FOLLOWED = "you don't follow this user" +USER_IS_ALREADY_FOLLOWED = "you follow this user already" + +WRONG_TOKEN_PREFIX = "unsupported authorization type" # noqa: S105 +MALFORMED_PAYLOAD = "could not validate credentials" + +ITEM_IS_ALREADY_FAVORITED = "you are already marked this items as favorite" +ITEM_IS_NOT_FAVORITED = "item is not favorited" + +COMMENT_DOES_NOT_EXIST = "comment does not exist" + +AUTHENTICATION_REQUIRED = "authentication required" diff --git a/.framework/python/backend/app/services/__init__.py b/.framework/python/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.framework/python/backend/app/services/authentication.py b/.framework/python/backend/app/services/authentication.py new file mode 100644 index 0000000..84539a6 --- /dev/null +++ b/.framework/python/backend/app/services/authentication.py @@ -0,0 +1,20 @@ +from app.db.errors import EntityDoesNotExist +from app.db.repositories.users import UsersRepository + + +async def check_username_is_taken(repo: UsersRepository, username: str) -> bool: + try: + await repo.get_user_by_username(username=username) + except EntityDoesNotExist: + return False + + return True + + +async def check_email_is_taken(repo: UsersRepository, email: str) -> bool: + try: + await repo.get_user_by_email(email=email) + except EntityDoesNotExist: + return False + + return True diff --git a/.framework/python/backend/app/services/event.py b/.framework/python/backend/app/services/event.py new file mode 100644 index 0000000..1de0618 --- /dev/null +++ b/.framework/python/backend/app/services/event.py @@ -0,0 +1,22 @@ +import os +import requests +import json + +PATH_TO_WILCO_ID = '../.wilco' +BASE_URL = os.environ.get('ENGINE_BASE_URL') or 'https://engine.wilco.gg' +WILCO_ID = os.environ.get('WILCO_ID') + +if not WILCO_ID and os.path.exists(PATH_TO_WILCO_ID): + with open(PATH_TO_WILCO_ID, 'r') as f: + WILCO_ID = f.read() + +EVENTS_ENDPOINT = f'{BASE_URL}/users/{WILCO_ID}/event' + +def send_event(event, metadata): + headers = { 'Content-type': 'application/json' } + data = { 'event': event, 'metadata': metadata } + try: + res = requests.post(EVENTS_ENDPOINT, data=json.dumps(data), headers=headers) + return res + except Exception as err: + print(f"failed to send event {event} to Wilco engine") diff --git a/.framework/python/backend/app/services/items.py b/.framework/python/backend/app/services/items.py new file mode 100644 index 0000000..b3f6f26 --- /dev/null +++ b/.framework/python/backend/app/services/items.py @@ -0,0 +1,23 @@ +from slugify import slugify + +from app.db.errors import EntityDoesNotExist +from app.db.repositories.items import ItemsRepository +from app.models.domain.items import Item +from app.models.domain.users import User + + +async def check_item_exists(items_repo: ItemsRepository, slug: str) -> bool: + try: + await items_repo.get_item_by_slug(slug=slug) + except EntityDoesNotExist: + return False + + return True + + +def get_slug_for_item(title: str) -> str: + return slugify(title) + + +def check_user_can_modify_item(item: Item, user: User) -> bool: + return item.seller.username == user.username diff --git a/.framework/python/backend/app/services/jwt.py b/.framework/python/backend/app/services/jwt.py new file mode 100644 index 0000000..355ecea --- /dev/null +++ b/.framework/python/backend/app/services/jwt.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta +from typing import Dict + +import jwt +from pydantic import ValidationError + +from app.models.domain.users import User +from app.models.schemas.jwt import JWTMeta, JWTUser + +JWT_SUBJECT = "access" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # one week + + +def create_jwt_token( + *, + jwt_content: Dict[str, str], + secret_key: str, + expires_delta: timedelta, +) -> str: + to_encode = jwt_content.copy() + expire = datetime.utcnow() + expires_delta + to_encode.update(JWTMeta(exp=expire, sub=JWT_SUBJECT).dict()) + return jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) + + +def create_access_token_for_user(user: User, secret_key: str) -> str: + return create_jwt_token( + jwt_content=JWTUser(username=user.username).dict(), + secret_key=secret_key, + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + ) + + +def get_username_from_token(token: str, secret_key: str) -> str: + try: + return JWTUser(**jwt.decode(token, secret_key, algorithms=[ALGORITHM])).username + except jwt.PyJWTError as decode_error: + raise ValueError("unable to decode JWT token") from decode_error + except ValidationError as validation_error: + raise ValueError("malformed payload in token") from validation_error diff --git a/.framework/python/backend/app/services/security.py b/.framework/python/backend/app/services/security.py new file mode 100644 index 0000000..08c523b --- /dev/null +++ b/.framework/python/backend/app/services/security.py @@ -0,0 +1,16 @@ +import bcrypt +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def generate_salt() -> str: + return bcrypt.gensalt().decode() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) diff --git a/.framework/python/backend/poetry.lock b/.framework/python/backend/poetry.lock new file mode 100644 index 0000000..1bbe72f --- /dev/null +++ b/.framework/python/backend/poetry.lock @@ -0,0 +1,2136 @@ +[[package]] +name = "aiosql" +version = "3.3.1" +description = "Simple SQL in Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +contextlib2 = ">=21.6.0" +typing-extensions = ">=3.7.4,<4" + +[[package]] +name = "alembic" +version = "1.7.6" +description = "A database migration tool for SQLAlchemy." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" + +[package.extras] +tz = ["python-dateutil"] + +[[package]] +name = "anyio" +version = "3.5.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] + +[[package]] +name = "asgi-lifespan" +version = "1.0.1" +description = "Programmatic startup/shutdown of ASGI apps." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +sniffio = "*" + +[[package]] +name = "asgiref" +version = "3.5.0" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + +[[package]] +name = "astor" +version = "0.8.1" +description = "Read/rewrite/write Python ASTs" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[[package]] +name = "asyncpg" +version = "0.25.0" +description = "An asyncio PostgreSQL driver" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["pycodestyle (>=2.7.0,<2.8.0)", "flake8 (>=3.9.2,<3.10.0)", "uvloop (>=0.15.3)"] + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "autoflake" +version = "1.4" +description = "Removes unused imports and unused variables" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pyflakes = ">=1.1.0" + +[[package]] +name = "bandit" +version = "1.7.2" +description = "Security oriented static analyser for python code." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +GitPython = ">=1.0.1" +PyYAML = ">=5.3.1" +stevedore = ">=1.20.0" + +[package.extras] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] +toml = ["toml"] +yaml = ["pyyaml"] + +[[package]] +name = "bcrypt" +version = "3.2.0" +description = "Modern password hashing for your software and your servers" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "black" +version = "22.1.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = ">=1.1.0" +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "cffi" +version = "1.15.0" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "2.0.11" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.0.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "contextlib2" +version = "21.6.0" +description = "Backports and enhancements for the contextlib module" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "coverage" +version = "6.4.1" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "darglint" +version = "1.8.1" +description = "A utility for ensuring Google-style docstrings stay up to date with the source code." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[[package]] +name = "databases" +version = "0.5.5" +description = "Async database support for Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +sqlalchemy = ">=1.4,<1.5" + +[package.extras] +mysql = ["aiomysql"] +mysql_asyncmy = ["asyncmy"] +postgresql = ["asyncpg"] +postgresql_aiopg = ["aiopg"] +sqlite = ["aiosqlite"] + +[[package]] +name = "dnspython" +version = "2.2.0" +description = "DNS toolkit" +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[package.extras] +dnssec = ["cryptography (>=2.6,<37.0)"] +curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] +doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.20)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + +[[package]] +name = "docutils" +version = "0.18.1" +description = "Docutils -- Python Documentation Utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "email-validator" +version = "1.1.3" +description = "A robust email syntax and deliverability validation library for Python 2.x/3.x." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +dnspython = ">=1.15.0" +idna = ">=2.0.0" + +[[package]] +name = "eradicate" +version = "2.0.0" +description = "Removes commented-out code." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "execnet" +version = "1.9.0" +description = "execnet: rapid multi-Python deployment" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +testing = ["pre-commit"] + +[[package]] +name = "fastapi" +version = "0.73.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.17.1" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] +test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] + +[[package]] +name = "flake8" +version = "3.9.2" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + +[[package]] +name = "flake8-bandit" +version = "2.1.2" +description = "Automated security testing with bandit and flake8." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +bandit = "*" +flake8 = "*" +flake8-polyfill = "*" +pycodestyle = "*" + +[[package]] +name = "flake8-broken-line" +version = "0.3.0" +description = "Flake8 plugin to forbid backslashes for line breaks" +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +flake8 = ">=3.5,<4.0" + +[[package]] +name = "flake8-bugbear" +version = "21.11.29" +description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +attrs = ">=19.2.0" +flake8 = ">=3.0.0" + +[package.extras] +dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"] + +[[package]] +name = "flake8-commas" +version = "2.1.0" +description = "Flake8 lint for trailing commas." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=2" + +[[package]] +name = "flake8-comprehensions" +version = "3.8.0" +description = "A flake8 plugin to help you write better list/set/dict comprehensions." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +flake8 = ">=3.0,<3.2.0 || >3.2.0" + +[[package]] +name = "flake8-debugger" +version = "4.0.0" +description = "ipdb/pdb statement checker plugin for flake8" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +flake8 = ">=3.0" +pycodestyle = "*" +six = "*" + +[[package]] +name = "flake8-docstrings" +version = "1.6.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-eradicate" +version = "1.2.0" +description = "Flake8 plugin to find commented out code" +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +attrs = "*" +eradicate = ">=2.0,<3.0" +flake8 = ">=3.5,<5" + +[[package]] +name = "flake8-fixme" +version = "1.1.1" +description = "Check for FIXME, TODO and other temporary developer notes. Plugin for flake8." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "flake8-isort" +version = "4.1.1" +description = "flake8 plugin that integrates isort ." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3.2.1,<5" +isort = ">=4.3.5,<6" +testfixtures = ">=6.8.0,<7" + +[package.extras] +test = ["pytest-cov"] + +[[package]] +name = "flake8-polyfill" +version = "1.0.2" +description = "Polyfill package for Flake8 plugins" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "flake8-quotes" +version = "3.3.1" +description = "Flake8 lint for quotes." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "flake8-rst-docstrings" +version = "0.2.5" +description = "Python docstring reStructuredText (RST) validator" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +flake8 = ">=3.0.0" +pygments = "*" +restructuredtext-lint = "*" + +[[package]] +name = "flake8-string-format" +version = "0.3.0" +description = "string format checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "gitdb" +version = "4.0.9" +description = "Git Object Database" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.26" +description = "GitPython is a python library used to interact with Git repositories" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "greenlet" +version = "1.1.2" +description = "Lightweight in-process concurrent programming" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.extras] +docs = ["sphinx"] + +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "httpcore" +version = "0.14.7" +description = "A minimal low-level HTTP client." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +anyio = ">=3.0.0,<4.0.0" +certifi = "*" +h11 = ">=0.11,<0.13" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.22.0" +description = "The next generation HTTP client." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +certifi = "*" +charset-normalizer = "*" +httpcore = ">=0.14.5,<0.15.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotlicffi", "brotli"] +cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "loguru" +version = "0.6.0" +description = "Python logging made (stupidly) simple" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "black (>=19.10b0)", "isort (>=5.1.1)", "Sphinx (>=4.1.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)"] + +[[package]] +name = "mako" +version = "1.1.6" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] + +[[package]] +name = "markupsafe" +version = "2.0.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy" +version = "0.931" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = ">=1.1.0" +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<2)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +bcrypt = {version = ">=3.1.0", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"] +totp = ["cryptography"] + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "pbr" +version = "5.8.1" +description = "Python Build Reasonableness" +category = "dev" +optional = false +python-versions = ">=2.6" + +[[package]] +name = "pep8-naming" +version = "0.11.1" +description = "Check PEP-8 naming conventions, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8-polyfill = ">=1.0.2,<2" + +[[package]] +name = "platformdirs" +version = "2.4.1" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.3" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydantic" +version = "1.9.0" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""} +python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""} +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pydocstyle" +version = "6.1.1" +description = "Python docstring style checker" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +snowballstemmer = "*" + +[package.extras] +toml = ["toml"] + +[[package]] +name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.11.2" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "pyjwt" +version = "2.3.0" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] + +[[package]] +name = "pyparsing" +version = "3.0.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pypika" +version = "0.48.8" +description = "A SQL query builder API for Python" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pytest" +version = "7.0.0" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.18.0" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=6.1.0" + +[package.extras] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-env" +version = "0.6.2" +description = "py.test plugin that allows you to add environment variables." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=2.6.0" + +[[package]] +name = "pytest-forked" +version = "1.4.0" +description = "run tests in isolated forked subprocesses" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +py = "*" +pytest = ">=3.10" + +[[package]] +name = "pytest-xdist" +version = "2.5.0" +description = "pytest xdist plugin for distributed testing and loop-on-failing modes" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +execnet = ">=1.1" +pytest = ">=6.2.0" +pytest-forked = "*" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-dotenv" +version = "0.19.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-slugify" +version = "5.0.2" +description = "A Python Slugify application that handles Unicode" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +text-unidecode = ">=1.3" + +[package.extras] +unidecode = ["Unidecode (>=1.1.1)"] + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.28.0" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2.0.0,<2.1.0" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "restructuredtext-lint" +version = "1.3.2" +description = "reStructuredText linter" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +docutils = ">=0.11,<1.0" + +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "smmap" +version = "5.0.0" +description = "A pure Python implementation of a sliding window memory map manager" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sqlalchemy" +version = "1.4.31" +description = "Database Abstraction Library" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] +aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"] +mariadb_connector = ["mariadb (>=1.0.1)"] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"] +mysql_connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] +postgresql_pg8000 = ["pg8000 (>=1.16.6)"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql (<1)", "pymysql"] +sqlcipher = ["sqlcipher3-binary"] + +[[package]] +name = "starlette" +version = "0.17.1" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +anyio = ">=3.0.0,<4" + +[package.extras] +full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] + +[[package]] +name = "stevedore" +version = "3.5.0" +description = "Manage dynamic plugins for Python applications" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "testfixtures" +version = "6.18.3" +description = "A collection of helpers and mock objects for unit tests and doc tests." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +build = ["setuptools-git", "wheel", "twine"] +docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] +test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "tomli" +version = "2.0.0" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "typing-extensions" +version = "3.10.0.2" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "unidecode" +version = "1.3.2" +description = "ASCII transliterations of Unicode text" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "urllib3" +version = "1.26.9" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "uvicorn" +version = "0.17.4" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +asgiref = ">=3.4.0" +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["websockets (>=10.0)", "httptools (>=0.2.0,<0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] + +[[package]] +name = "wemake-python-styleguide" +version = "0.16.0" +description = "The strictest and most opinionated python linter ever" +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +astor = ">=0.8,<0.9" +attrs = "*" +darglint = ">=1.2,<2.0" +flake8 = ">=3.7,<5" +flake8-bandit = ">=2.1,<3.0" +flake8-broken-line = ">=0.3,<0.5" +flake8-bugbear = ">=20.1,<22.0" +flake8-commas = ">=2.0,<3.0" +flake8-comprehensions = ">=3.1,<4.0" +flake8-debugger = ">=4.0,<5.0" +flake8-docstrings = ">=1.3,<2.0" +flake8-eradicate = ">=1.0,<2.0" +flake8-isort = ">=4.0,<5.0" +flake8-quotes = ">=3.0,<4.0" +flake8-rst-docstrings = ">=0.2.3,<0.3.0" +flake8-string-format = ">=0.3,<0.4" +pep8-naming = ">=0.11,<0.13" +pygments = ">=2.4,<3.0" +typing_extensions = ">=3.6,<5.0" + +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] + +[metadata] +lock-version = "1.1" +python-versions = "3.9.13" +content-hash = "cedb44c416663f147800d5ee734ba784fc0113120f236dc9675394eaadad0eca" + +[metadata.files] +aiosql = [ + {file = "aiosql-3.3.1-py3-none-any.whl", hash = "sha256:467067f2d237e2ccc47a3f651ae6bd128d89287a5dbf1357c065c9be37947cbc"}, + {file = "aiosql-3.3.1.tar.gz", hash = "sha256:e7144b0e96c2783b79002657497b3a16ee41068290adc1e89cf08cb3974c76fa"}, +] +alembic = [ + {file = "alembic-1.7.6-py3-none-any.whl", hash = "sha256:ad842f2c3ab5c5d4861232730779c05e33db4ba880a08b85eb505e87c01095bc"}, + {file = "alembic-1.7.6.tar.gz", hash = "sha256:6c0c05e9768a896d804387e20b299880fe01bc56484246b0dffe8075d6d3d847"}, +] +anyio = [ + {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"}, + {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, +] +asgi-lifespan = [ + {file = "asgi-lifespan-1.0.1.tar.gz", hash = "sha256:9a33e7da2073c4764bc79bd6136501d6c42f60e3d2168ba71235e84122eadb7f"}, + {file = "asgi_lifespan-1.0.1-py3-none-any.whl", hash = "sha256:9ea969dc5eb5cf08e52c08dce6f61afcadd28112e72d81c972b1d8eb8691ab53"}, +] +asgiref = [ + {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, + {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, +] +astor = [ + {file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"}, + {file = "astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e"}, +] +asyncpg = [ + {file = "asyncpg-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3"}, + {file = "asyncpg-0.25.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a"}, + {file = "asyncpg-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a70783f6ffa34cc7dd2de20a873181414a34fd35a4a208a1f1a7f9f695e4ec4"}, + {file = "asyncpg-0.25.0-cp310-cp310-win32.whl", hash = "sha256:43cde84e996a3afe75f325a68300093425c2f47d340c0fc8912765cf24a1c095"}, + {file = "asyncpg-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:56d88d7ef4341412cd9c68efba323a4519c916979ba91b95d4c08799d2ff0c09"}, + {file = "asyncpg-0.25.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a84d30e6f850bac0876990bcd207362778e2208df0bee8be8da9f1558255e634"}, + {file = "asyncpg-0.25.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:beaecc52ad39614f6ca2e48c3ca15d56e24a2c15cbfdcb764a4320cc45f02fd5"}, + {file = "asyncpg-0.25.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:6f8f5fc975246eda83da8031a14004b9197f510c41511018e7b1bedde6968e92"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:ddb4c3263a8d63dcde3d2c4ac1c25206bfeb31fa83bd70fd539e10f87739dee4"}, + {file = "asyncpg-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:bf6dc9b55b9113f39eaa2057337ce3f9ef7de99a053b8a16360395ce588925cd"}, + {file = "asyncpg-0.25.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acb311722352152936e58a8ee3c5b8e791b24e84cd7d777c414ff05b3530ca68"}, + {file = "asyncpg-0.25.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0a61fb196ce4dae2f2fa26eb20a778db21bbee484d2e798cb3cc988de13bdd1b"}, + {file = "asyncpg-0.25.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2633331cbc8429030b4f20f712f8d0fbba57fa8555ee9b2f45f981b81328b256"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:863d36eba4a7caa853fd7d83fad5fd5306f050cc2fe6e54fbe10cdb30420e5e9"}, + {file = "asyncpg-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fe471ccd915b739ca65e2e4dbd92a11b44a5b37f2e38f70827a1c147dafe0fa8"}, + {file = "asyncpg-0.25.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72a1e12ea0cf7c1e02794b697e3ca967b2360eaa2ce5d4bfdd8604ec2d6b774b"}, + {file = "asyncpg-0.25.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4327f691b1bdb222df27841938b3e04c14068166b3a97491bec2cb982f49f03e"}, + {file = "asyncpg-0.25.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:739bbd7f89a2b2f6bc44cb8bf967dab12c5bc714fcbe96e68d512be45ecdf962"}, + {file = "asyncpg-0.25.0-cp38-cp38-win32.whl", hash = "sha256:18d49e2d93a7139a2fdbd113e320cc47075049997268a61bfbe0dde680c55471"}, + {file = "asyncpg-0.25.0-cp38-cp38-win_amd64.whl", hash = "sha256:191fe6341385b7fdea7dbdcf47fd6db3fd198827dcc1f2b228476d13c05a03c6"}, + {file = "asyncpg-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fab7f1b2c29e187dd8781fce896249500cf055b63471ad66332e537e9b5f7e"}, + {file = "asyncpg-0.25.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a738f1b2876f30d710d3dc1e7858160a0afe1603ba16bf5f391f5316eb0ed855"}, + {file = "asyncpg-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4105f57ad1e8fbc8b1e535d8fcefa6ce6c71081228f08680c6dea24384ff0e"}, + {file = "asyncpg-0.25.0-cp39-cp39-win32.whl", hash = "sha256:f55918ded7b85723a5eaeb34e86e7b9280d4474be67df853ab5a7fa0cc7c6bf2"}, + {file = "asyncpg-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:649e2966d98cc48d0646d9a4e29abecd8b59d38d55c256d5c857f6b27b7407ac"}, + {file = "asyncpg-0.25.0.tar.gz", hash = "sha256:63f8e6a69733b285497c2855464a34de657f2cccd25aeaeeb5071872e9382540"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +autoflake = [ + {file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"}, +] +bandit = [ + {file = "bandit-1.7.2-py3-none-any.whl", hash = "sha256:e20402cadfd126d85b68ed4c8862959663c8c372dbbb1fca8f8e2c9f55a067ec"}, + {file = "bandit-1.7.2.tar.gz", hash = "sha256:6d11adea0214a43813887bfe71a377b5a9955e4c826c8ffd341b494e3ab25260"}, +] +bcrypt = [ + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b589229207630484aefe5899122fb938a5b017b0f4349f769b8c13e78d99a8fd"}, + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a0584a92329210fcd75eb8a3250c5a941633f8bfaf2a18f81009b097732839b7"}, + {file = "bcrypt-3.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:56e5da069a76470679f312a7d3d23deb3ac4519991a0361abc11da837087b61d"}, + {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"}, + {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"}, + {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, +] +black = [ + {file = "black-22.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6"}, + {file = "black-22.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866"}, + {file = "black-22.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71"}, + {file = "black-22.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab"}, + {file = "black-22.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5"}, + {file = "black-22.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a"}, + {file = "black-22.1.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0"}, + {file = "black-22.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba"}, + {file = "black-22.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1"}, + {file = "black-22.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8"}, + {file = "black-22.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28"}, + {file = "black-22.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912"}, + {file = "black-22.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3"}, + {file = "black-22.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3"}, + {file = "black-22.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61"}, + {file = "black-22.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd"}, + {file = "black-22.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f"}, + {file = "black-22.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0"}, + {file = "black-22.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c"}, + {file = "black-22.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2"}, + {file = "black-22.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321"}, + {file = "black-22.1.0-py3-none-any.whl", hash = "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d"}, + {file = "black-22.1.0.tar.gz", hash = "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +cffi = [ + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.11.tar.gz", hash = "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"}, + {file = "charset_normalizer-2.0.11-py3-none-any.whl", hash = "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45"}, +] +click = [ + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +contextlib2 = [ + {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, + {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, +] +coverage = [ + {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, + {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, + {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, + {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, + {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, + {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, + {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, + {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, + {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, + {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, + {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, + {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, + {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, + {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, + {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, + {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, + {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, + {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, + {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, + {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, + {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, + {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, + {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, +] +darglint = [ + {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"}, + {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"}, +] +databases = [ + {file = "databases-0.5.5-py3-none-any.whl", hash = "sha256:97d9b9647216d1ab53ca61c059412b5c7b6e1f0bf8ce985477982ebcc7f278f3"}, + {file = "databases-0.5.5.tar.gz", hash = "sha256:02c6b016c1c951c21cca281dc8e2e002c60dc44026c0084aabbd8c37514aeb37"}, +] +dnspython = [ + {file = "dnspython-2.2.0-py3-none-any.whl", hash = "sha256:081649da27ced5e75709a1ee542136eaba9842a0fe4c03da4fb0a3d3ed1f3c44"}, + {file = "dnspython-2.2.0.tar.gz", hash = "sha256:e79351e032d0b606b98d38a4b0e6e2275b31a5b85c873e587cc11b73aca026d6"}, +] +docutils = [ + {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, + {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, +] +email-validator = [ + {file = "email_validator-1.1.3-py2.py3-none-any.whl", hash = "sha256:5675c8ceb7106a37e40e2698a57c056756bf3f272cfa8682a4f87ebd95d8440b"}, + {file = "email_validator-1.1.3.tar.gz", hash = "sha256:aa237a65f6f4da067119b7df3f13e89c25c051327b2b5b66dc075f33d62480d7"}, +] +eradicate = [ + {file = "eradicate-2.0.0.tar.gz", hash = "sha256:27434596f2c5314cc9b31410c93d8f7e8885747399773cd088d3adea647a60c8"}, +] +execnet = [ + {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"}, + {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, +] +fastapi = [ + {file = "fastapi-0.73.0-py3-none-any.whl", hash = "sha256:f0a618aff5f6942862f2d3f20f39b1c037e33314d1b8207fd1c3a2cca76dfd8c"}, + {file = "fastapi-0.73.0.tar.gz", hash = "sha256:dcfee92a7f9a72b5d4b7ca364bd2b009f8fc10d95ed5769be20e94f39f7e5a15"}, +] +flake8 = [ + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] +flake8-bandit = [ + {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"}, +] +flake8-broken-line = [ + {file = "flake8-broken-line-0.3.0.tar.gz", hash = "sha256:f74e052833324a9e5f0055032f7ccc54b23faabafe5a26241c2f977e70b10b50"}, + {file = "flake8_broken_line-0.3.0-py3-none-any.whl", hash = "sha256:611f79c7f27118e7e5d3dc098ef7681c40aeadf23783700c5dbee840d2baf3af"}, +] +flake8-bugbear = [ + {file = "flake8-bugbear-21.11.29.tar.gz", hash = "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"}, + {file = "flake8_bugbear-21.11.29-py36.py37.py38-none-any.whl", hash = "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82"}, +] +flake8-commas = [ + {file = "flake8-commas-2.1.0.tar.gz", hash = "sha256:940441ab8ee544df564ae3b3f49f20462d75d5c7cac2463e0b27436e2050f263"}, + {file = "flake8_commas-2.1.0-py2.py3-none-any.whl", hash = "sha256:ebb96c31e01d0ef1d0685a21f3f0e2f8153a0381430e748bf0bbbb5d5b453d54"}, +] +flake8-comprehensions = [ + {file = "flake8-comprehensions-3.8.0.tar.gz", hash = "sha256:8e108707637b1d13734f38e03435984f6b7854fa6b5a4e34f93e69534be8e521"}, + {file = "flake8_comprehensions-3.8.0-py3-none-any.whl", hash = "sha256:9406314803abe1193c064544ab14fdc43c58424c0882f6ff8a581eb73fc9bb58"}, +] +flake8-debugger = [ + {file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"}, + {file = "flake8_debugger-4.0.0-py3-none-any.whl", hash = "sha256:82e64faa72e18d1bdd0000407502ebb8ecffa7bc027c62b9d4110ce27c091032"}, +] +flake8-docstrings = [ + {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, + {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, +] +flake8-eradicate = [ + {file = "flake8-eradicate-1.2.0.tar.gz", hash = "sha256:acaa1b6839ff00d284b805c432fdfa6047262bd15a5504ec945797e87b4de1fa"}, + {file = "flake8_eradicate-1.2.0-py3-none-any.whl", hash = "sha256:51dc660d0c1c1ed93af0f813540bbbf72ab2d3466c14e3f3bac371c618b6042f"}, +] +flake8-fixme = [ + {file = "flake8-fixme-1.1.1.tar.gz", hash = "sha256:50cade07d27a4c30d4f12351478df87339e67640c83041b664724bda6d16f33a"}, + {file = "flake8_fixme-1.1.1-py2.py3-none-any.whl", hash = "sha256:226a6f2ef916730899f29ac140bed5d4a17e5aba79f00a0e3ae1eff1997cb1ac"}, +] +flake8-isort = [ + {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, + {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, +] +flake8-polyfill = [ + {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, + {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, +] +flake8-quotes = [ + {file = "flake8-quotes-3.3.1.tar.gz", hash = "sha256:633adca6fb8a08131536af0d750b44d6985b9aba46f498871e21588c3e6f525a"}, +] +flake8-rst-docstrings = [ + {file = "flake8-rst-docstrings-0.2.5.tar.gz", hash = "sha256:4fe93f997dea45d9d3c8bd220f12f0b6c359948fb943b5b48021a3f927edd816"}, + {file = "flake8_rst_docstrings-0.2.5-py3-none-any.whl", hash = "sha256:b99d9041b769b857efe45a448dc8c71b1bb311f9cacbdac5de82f96498105082"}, +] +flake8-string-format = [ + {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, + {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, +] +gitdb = [ + {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, + {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, +] +gitpython = [ + {file = "GitPython-3.1.26-py3-none-any.whl", hash = "sha256:26ac35c212d1f7b16036361ca5cff3ec66e11753a0d677fb6c48fa4e1a9dd8d6"}, + {file = "GitPython-3.1.26.tar.gz", hash = "sha256:fc8868f63a2e6d268fb25f481995ba185a85a66fcad126f039323ff6635669ee"}, +] +greenlet = [ + {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"}, + {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"}, + {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"}, + {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"}, + {file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"}, + {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"}, + {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"}, + {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"}, + {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"}, + {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"}, + {file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"}, + {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"}, + {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"}, + {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"}, + {file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"}, + {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"}, + {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"}, + {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"}, + {file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"}, + {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"}, + {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"}, + {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"}, + {file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"}, + {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"}, + {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, + {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, +] +gunicorn = [ + {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, + {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, +] +h11 = [ + {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, + {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, +] +httpcore = [ + {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"}, + {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"}, +] +httpx = [ + {file = "httpx-0.22.0-py3-none-any.whl", hash = "sha256:e35e83d1d2b9b2a609ef367cc4c1e66fd80b750348b20cc9e19d1952fc2ca3f6"}, + {file = "httpx-0.22.0.tar.gz", hash = "sha256:d8e778f76d9bbd46af49e7f062467e3157a5a3d2ae4876a4bbfd8a51ed9c9cb4"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +loguru = [ + {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"}, + {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"}, +] +mako = [ + {file = "Mako-1.1.6-py2.py3-none-any.whl", hash = "sha256:afaf8e515d075b22fad7d7b8b30e4a1c90624ff2f3733a06ec125f5a5f043a57"}, + {file = "Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2"}, +] +markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy = [ + {file = "mypy-0.931-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a"}, + {file = "mypy-0.931-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00"}, + {file = "mypy-0.931-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714"}, + {file = "mypy-0.931-cp310-cp310-win_amd64.whl", hash = "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc"}, + {file = "mypy-0.931-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d"}, + {file = "mypy-0.931-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d"}, + {file = "mypy-0.931-cp36-cp36m-win_amd64.whl", hash = "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c"}, + {file = "mypy-0.931-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0"}, + {file = "mypy-0.931-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05"}, + {file = "mypy-0.931-cp37-cp37m-win_amd64.whl", hash = "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7"}, + {file = "mypy-0.931-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0"}, + {file = "mypy-0.931-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069"}, + {file = "mypy-0.931-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799"}, + {file = "mypy-0.931-cp38-cp38-win_amd64.whl", hash = "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a"}, + {file = "mypy-0.931-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166"}, + {file = "mypy-0.931-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266"}, + {file = "mypy-0.931-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd"}, + {file = "mypy-0.931-cp39-cp39-win_amd64.whl", hash = "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697"}, + {file = "mypy-0.931-py3-none-any.whl", hash = "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d"}, + {file = "mypy-0.931.tar.gz", hash = "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +passlib = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +pbr = [ + {file = "pbr-5.8.1-py2.py3-none-any.whl", hash = "sha256:27108648368782d07bbf1cb468ad2e2eeef29086affd14087a6d04b7de8af4ec"}, + {file = "pbr-5.8.1.tar.gz", hash = "sha256:66bc5a34912f408bb3925bf21231cb6f59206267b7f63f3503ef865c1a292e25"}, +] +pep8-naming = [ + {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, + {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, +] +platformdirs = [ + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +psycopg2-binary = [ + {file = "psycopg2-binary-2.9.3.tar.gz", hash = "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-win32.whl", hash = "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029"}, + {file = "psycopg2_binary-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b"}, + {file = "psycopg2_binary-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba"}, + {file = "psycopg2_binary-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-win32.whl", hash = "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce"}, + {file = "psycopg2_binary-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-win32.whl", hash = "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d"}, + {file = "psycopg2_binary-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pycodestyle = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, +] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pydantic = [ + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, +] +pydocstyle = [ + {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, + {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, +] +pyflakes = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, +] +pygments = [ + {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, + {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, +] +pyjwt = [ + {file = "PyJWT-2.3.0-py3-none-any.whl", hash = "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f"}, + {file = "PyJWT-2.3.0.tar.gz", hash = "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41"}, +] +pyparsing = [ + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, +] +pypika = [ + {file = "pypika-0.48.8.tar.gz", hash = "sha256:45af481d8523d60f87e308dee6ff5c454f331c8ce3a675e5398fbea6c20fe1b1"}, +] +pytest = [ + {file = "pytest-7.0.0-py3-none-any.whl", hash = "sha256:42901e6bd4bd4a0e533358a86e848427a49005a3256f657c5c8f8dd35ef137a9"}, + {file = "pytest-7.0.0.tar.gz", hash = "sha256:dad48ffda394e5ad9aa3b7d7ddf339ed502e5e365b1350e0af65f4a602344b11"}, +] +pytest-asyncio = [ + {file = "pytest-asyncio-0.18.0.tar.gz", hash = "sha256:5c510e5d3ad0f97bab0ae0223363d2aa6329bbbafb0981d96dbed6a804a99349"}, + {file = "pytest_asyncio-0.18.0-py3-none-any.whl", hash = "sha256:5e33f5010402309ff4e8cdec04e76b057ae73e0c132f12c6aa2fa6ec8cabfbf1"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +pytest-env = [ + {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, +] +pytest-forked = [ + {file = "pytest-forked-1.4.0.tar.gz", hash = "sha256:8b67587c8f98cbbadfdd804539ed5455b6ed03802203485dd2f53c1422d7440e"}, + {file = "pytest_forked-1.4.0-py3-none-any.whl", hash = "sha256:bbbb6717efc886b9d64537b41fb1497cfaf3c9601276be8da2cccfea5a3c8ad8"}, +] +pytest-xdist = [ + {file = "pytest-xdist-2.5.0.tar.gz", hash = "sha256:4580deca3ff04ddb2ac53eba39d76cb5dd5edeac050cb6fbc768b0dd712b4edf"}, + {file = "pytest_xdist-2.5.0-py3-none-any.whl", hash = "sha256:6fe5c74fec98906deb8f2d2b616b5c782022744978e7bd4695d39c8f42d0ce65"}, +] +python-dotenv = [ + {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"}, + {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"}, +] +python-slugify = [ + {file = "python-slugify-5.0.2.tar.gz", hash = "sha256:f13383a0b9fcbe649a1892b9c8eb4f8eab1d6d84b84bb7a624317afa98159cab"}, + {file = "python_slugify-5.0.2-py2.py3-none-any.whl", hash = "sha256:6d8c5df75cd4a7c3a2d21e257633de53f52ab0265cd2d1dc62a730e8194a7380"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +requests = [ + {file = "requests-2.28.0-py3-none-any.whl", hash = "sha256:bc7861137fbce630f17b03d3ad02ad0bf978c844f3536d0edda6499dafce2b6f"}, + {file = "requests-2.28.0.tar.gz", hash = "sha256:d568723a7ebd25875d8d1eaf5dfa068cd2fc8194b2e483d7b1f7c81918dbec6b"}, +] +restructuredtext-lint = [ + {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"}, +] +rfc3986 = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +smmap = [ + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, +] +sniffio = [ + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] +sqlalchemy = [ + {file = "SQLAlchemy-1.4.31-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c3abc34fed19fdeaead0ced8cf56dd121f08198008c033596aa6aae7cc58f59f"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8d0949b11681380b4a50ac3cd075e4816afe9fa4a8c8ae006c1ca26f0fa40ad8"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27m-win32.whl", hash = "sha256:f3b7ec97e68b68cb1f9ddb82eda17b418f19a034fa8380a0ac04e8fe01532875"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27m-win_amd64.whl", hash = "sha256:81f2dd355b57770fdf292b54f3e0a9823ec27a543f947fa2eb4ec0df44f35f0d"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4ad31cec8b49fd718470328ad9711f4dc703507d434fd45461096da0a7135ee0"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:05fa14f279d43df68964ad066f653193187909950aa0163320b728edfc400167"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dccff41478050e823271642837b904d5f9bda3f5cf7d371ce163f00a694118d6"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57205844f246bab9b666a32f59b046add8995c665d9ecb2b7b837b087df90639"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8210090a816d48a4291a47462bac750e3bc5c2442e6d64f7b8137a7c3f9ac5"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-win32.whl", hash = "sha256:2e216c13ecc7fcdcbb86bb3225425b3ed338e43a8810c7089ddb472676124b9b"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-win_amd64.whl", hash = "sha256:e3a86b59b6227ef72ffc10d4b23f0fe994bef64d4667eab4fb8cd43de4223bec"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:2fd4d3ca64c41dae31228b80556ab55b6489275fb204827f6560b65f95692cf3"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f22c040d196f841168b1456e77c30a18a3dc16b336ddbc5a24ce01ab4e95ae0"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c0c7171aa5a57e522a04a31b84798b6c926234cb559c0939840c3235cf068813"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d046a9aeba9bc53e88a41e58beb72b6205abb9a20f6c136161adf9128e589db5"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-win32.whl", hash = "sha256:d86132922531f0dc5a4f424c7580a472a924dd737602638e704841c9cb24aea2"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-win_amd64.whl", hash = "sha256:ca68c52e3cae491ace2bf39b35fef4ce26c192fd70b4cd90f040d419f70893b5"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:cf2cd387409b12d0a8b801610d6336ee7d24043b6dd965950eaec09b73e7262f"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb4b15fb1f0aafa65cbdc62d3c2078bea1ceecbfccc9a1f23a2113c9ac1191fa"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c317ddd7c586af350a6aef22b891e84b16bff1a27886ed5b30f15c1ed59caeaa"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c7ed6c69debaf6198fadb1c16ae1253a29a7670bbf0646f92582eb465a0b999"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-win32.whl", hash = "sha256:6a01ec49ca54ce03bc14e10de55dfc64187a2194b3b0e5ac0fdbe9b24767e79e"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-win_amd64.whl", hash = "sha256:330eb45395874cc7787214fdd4489e2afb931bc49e0a7a8f9cd56d6e9c5b1639"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5e9c7b3567edbc2183607f7d9f3e7e89355b8f8984eec4d2cd1e1513c8f7b43f"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de85c26a5a1c72e695ab0454e92f60213b4459b8d7c502e0be7a6369690eeb1a"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:975f5c0793892c634c4920057da0de3a48bbbbd0a5c86f5fcf2f2fedf41b76da"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5c20c8415173b119762b6110af64448adccd4d11f273fb9f718a9865b88a99c"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-win32.whl", hash = "sha256:b35dca159c1c9fa8a5f9005e42133eed82705bf8e243da371a5e5826440e65ca"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-win_amd64.whl", hash = "sha256:b7b20c88873675903d6438d8b33fba027997193e274b9367421e610d9da76c08"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:85e4c244e1de056d48dae466e9baf9437980c19fcde493e0db1a0a986e6d75b4"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79e73d5ee24196d3057340e356e6254af4d10e1fc22d3207ea8342fc5ffb977"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:15a03261aa1e68f208e71ae3cd845b00063d242cbf8c87348a0c2c0fc6e1f2ac"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc5e5ccc0160e7ad190e5c61eb57560f38559e22586955f205e537cda26034"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-win32.whl", hash = "sha256:289465162b1fa1e7a982f8abe59d26a8331211cad4942e8031d2b7db1f75e649"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-win_amd64.whl", hash = "sha256:9e4fb2895b83993831ba2401b6404de953fdbfa9d7d4fa6a4756294a83bbc94f"}, + {file = "SQLAlchemy-1.4.31.tar.gz", hash = "sha256:582b59d1e5780a447aada22b461e50b404a9dc05768da1d87368ad8190468418"}, +] +starlette = [ + {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, + {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"}, +] +stevedore = [ + {file = "stevedore-3.5.0-py3-none-any.whl", hash = "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c"}, + {file = "stevedore-3.5.0.tar.gz", hash = "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"}, +] +testfixtures = [ + {file = "testfixtures-6.18.3-py2.py3-none-any.whl", hash = "sha256:6ddb7f56a123e1a9339f130a200359092bd0a6455e31838d6c477e8729bb7763"}, + {file = "testfixtures-6.18.3.tar.gz", hash = "sha256:2600100ae96ffd082334b378e355550fef8b4a529a6fa4c34f47130905c7426d"}, +] +text-unidecode = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] +tomli = [ + {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, + {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, +] +typing-extensions = [ + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, +] +unidecode = [ + {file = "Unidecode-1.3.2-py3-none-any.whl", hash = "sha256:215fe33c9d1c889fa823ccb66df91b02524eb8cc8c9c80f9c5b8129754d27829"}, + {file = "Unidecode-1.3.2.tar.gz", hash = "sha256:669898c1528912bcf07f9819dc60df18d057f7528271e31f8ec28cc88ef27504"}, +] +urllib3 = [ + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, +] +uvicorn = [ + {file = "uvicorn-0.17.4-py3-none-any.whl", hash = "sha256:e85872d84fb651cccc4c5d2a71cf7ead055b8fb4d8f1e78e36092282c0cf2aec"}, + {file = "uvicorn-0.17.4.tar.gz", hash = "sha256:25850bbc86195a71a6477b3e4b3b7b4c861fb687fb96912972ce5324472b1011"}, +] +wemake-python-styleguide = [ + {file = "wemake-python-styleguide-0.16.0.tar.gz", hash = "sha256:3bf0a4962404e6fd6fa479e72e2ba3fb75d5920ea6c44b72b45240c9e519543c"}, + {file = "wemake_python_styleguide-0.16.0-py3-none-any.whl", hash = "sha256:8caa92b4aa77b08a505d718553238812d1b612b1036bc171ca3aa18345efe0b4"}, +] +win32-setctime = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] diff --git a/.framework/python/backend/pyproject.toml b/.framework/python/backend/pyproject.toml new file mode 100644 index 0000000..b974ae6 --- /dev/null +++ b/.framework/python/backend/pyproject.toml @@ -0,0 +1,72 @@ +[tool.poetry] +name = "Anythink Market Backend" +version = "0.0.0" +description = "Backend logic implementation for Anythink Market" +authors=["Anythink"] +license = "MIT" + +[tool.poetry.dependencies] +python = "3.9.13" +uvicorn = "^0.17.4" +fastapi = "^0.73.0" +pydantic = { version = "^1.8", extras = ["email", "dotenv"] } +passlib = { version = "^1.7", extras = ["bcrypt"] } +pyjwt = "^2.3" +databases = "^0.5.5" +asyncpg = "^0.25.0" +psycopg2-binary = "^2.9.3" +aiosql = "^3.3.1" +pypika = "^0.48.8" +alembic = "^1.7" +python-slugify = "^5.0" +Unidecode = "^1.3" +loguru = "^0.6.0" +requests = "^2.28.0" +gunicorn = "^20.1.0" + +[tool.poetry.dev-dependencies] +black = "^22.1.0" +isort = "^5.10" +autoflake = "^1.4" +wemake-python-styleguide = "^0.16.0" +mypy = "^0.931" +flake8-fixme = "^1.1" +pytest = "^7.0" +pytest-cov = "^3.0" +pytest-asyncio = "^0.18.0" +pytest-env = "^0.6.2" +pytest-xdist = "^2.4.0" +httpx = "^0.22.0" +asgi-lifespan = "^1.0.1" + +[tool.isort] +profile = "black" +src_paths = ["app", "tests"] +combine_as_imports = true + +[tool.pytest.ini_options] +testpaths = "tests" +filterwarnings = "error" +addopts = ''' + --strict-markers + --tb=short + --cov=app + --cov=tests + --cov-branch + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --no-cov-on-fail + --cov-fail-under=100 + --numprocesses=auto + --asyncio-mode=auto +''' +env = [ + "SECRET_KEY=secret", + "MAX_CONNECTIONS_COUNT=1", + "MIN_CONNECTIONS_COUNT=1" +] + +[build-system] +requires = ["poetry>=1.0"] +build-backend = "poetry.masonry.api" diff --git a/.framework/python/backend/requirements.txt b/.framework/python/backend/requirements.txt new file mode 100644 index 0000000..4731c9d --- /dev/null +++ b/.framework/python/backend/requirements.txt @@ -0,0 +1,43 @@ +aiosql==3.3.1 +alembic==1.7.6 +anyio==3.5.0 +asgiref==3.5.0 +asyncpg==0.25.0 +bcrypt==3.2.0 +certifi==2021.10.8 +cffi==1.15.0 +charset-normalizer==2.0.11 +click==8.0.3 +colorama==0.4.4; platform_system == "Windows" +colorama==0.4.4; sys_platform == "win32" +contextlib2==21.6.0 +databases==0.5.5 +dnspython==2.2.0 +email-validator==1.1.3 +fastapi==0.73.0 +greenlet==1.1.2; python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") +gunicorn==20.1.0 +h11==0.12.0 +idna==3.3 +loguru==0.6.0 +mako==1.1.6 +markupsafe==2.0.1 +passlib==1.7.4 +psycopg2-binary==2.9.3 +pycparser==2.21 +pydantic==1.9.0 +pyjwt==2.3.0 +pypika==0.48.8 +python-dotenv==0.19.2 +python-slugify==5.0.2 +requests==2.28.0 +six==1.16.0 +sniffio==1.2.0 +sqlalchemy==1.4.31 +starlette==0.17.1 +text-unidecode==1.3 +typing-extensions==3.10.0.2 +unidecode==1.3.2 +urllib3==1.26.9 +uvicorn==0.17.4 +win32-setctime==1.1.0; sys_platform == "win32" diff --git a/.framework/python/backend/runtime.txt b/.framework/python/backend/runtime.txt new file mode 100644 index 0000000..c6f7782 --- /dev/null +++ b/.framework/python/backend/runtime.txt @@ -0,0 +1 @@ +python-3.9.13 diff --git a/.framework/python/backend/scripts/format b/.framework/python/backend/scripts/format new file mode 100755 index 0000000..64a9b14 --- /dev/null +++ b/.framework/python/backend/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +isort --force-single-line-imports app tests +autoflake --recursive --remove-all-unused-imports --remove-unused-variables --in-place app tests +black app tests +isort app tests diff --git a/.framework/python/backend/scripts/heroku_release.sh b/.framework/python/backend/scripts/heroku_release.sh new file mode 100755 index 0000000..95d8fe5 --- /dev/null +++ b/.framework/python/backend/scripts/heroku_release.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# Usage: bin/heroku_deploy + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NO_COLOR='\033[0m' + +set -euo pipefail + +printf "\n⏳${YELLOW} [Release Phase]: Running schema migrations.${NO_COLOR}\n" +alembic upgrade head +printf "\n⏳${YELLOW} [Release Phase]: Seeding.${NO_COLOR}\n" +./seeds.sh +printf "\n🎉${GREEN} [Release Phase]: Database is up to date.${NO_COLOR}\n" diff --git a/.framework/python/backend/scripts/lint b/.framework/python/backend/scripts/lint new file mode 100755 index 0000000..ea56cfe --- /dev/null +++ b/.framework/python/backend/scripts/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e +set -x + + +flake8 app --exclude=app/db/migrations +mypy app + +black --check app --diff +isort --check-only app diff --git a/.framework/python/backend/scripts/test b/.framework/python/backend/scripts/test new file mode 100755 index 0000000..23f48d1 --- /dev/null +++ b/.framework/python/backend/scripts/test @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +pytest --cov=app --cov=tests --cov-report=term-missing --cov-config=setup.cfg ${@} diff --git a/.framework/python/backend/scripts/test-cov-html b/.framework/python/backend/scripts/test-cov-html new file mode 100755 index 0000000..de5f3b1 --- /dev/null +++ b/.framework/python/backend/scripts/test-cov-html @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +bash scripts/test --cov-report=html ${@} diff --git a/.framework/python/backend/seeds.sh b/.framework/python/backend/seeds.sh new file mode 100755 index 0000000..eab281e --- /dev/null +++ b/.framework/python/backend/seeds.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +python3 ./app/db/seeds.py diff --git a/.framework/python/backend/setup.cfg b/.framework/python/backend/setup.cfg new file mode 100644 index 0000000..c880ac5 --- /dev/null +++ b/.framework/python/backend/setup.cfg @@ -0,0 +1,88 @@ +[coverage:report] +precision = 2 +exclude_lines = + pragma: no cover + raise NotImplementedError + raise NotImplemented + +[coverage:run] +source = app +branch = True + +[mypy] +plugins = pydantic.mypy + +strict_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +disallow_any_generics = True +check_untyped_defs = True + +disallow_untyped_defs = True + +[pydantic-mypy] +init_forbid_extra = True +init_typed = True +warn_required_dynamic_aliases = True +warn_untyped_fields = True + +[mypy-sqlalchemy.*] +ignore_missing_imports = True + +[mypy-alembic.*] +ignore_missing_imports = True + +[mypy-loguru.*] +ignore_missing_imports = True + +[mypy-asyncpg.*] +ignore_missing_imports = True + +[mypy-bcrypt.*] +ignore_missing_imports = True + +[mypy-passlib.*] +ignore_missing_imports = True + +[mypy-slugify.*] +ignore_missing_imports = True + +[mypy-pypika.*] +ignore_missing_imports = True + +[flake8] +format = wemake +max-line-length = 88 +per-file-ignores = + # ignore error on builtin names for TypedTable classes, since just mapper for SQL table + app/db/queries/tables.py: WPS125, + + # ignore black disabling in some places for queries building using pypika + app/db/repositories/*.py: E800, + + app/api/dependencies/authentication.py: WPS201, +ignore = + # common errors: + # FastAPI architecture requires a lot of functions calls as default arguments, so ignore it here. + B008, + # docs are missing in this project. + D, RST + + # WPS: 3xx + # IMO, but the obligation to specify the base class is redundant. + WPS306, + + # WPS: 4xx + # FastAPI architecture requires a lot of complex calls as default arguments, so ignore it here. + WPS404, + # again, FastAPI DI architecture involves a lot of nested functions as DI providers. + WPS430, + # used for pypika operations + WPS465, + + # WPS: 6xx + # pydantic defines models in dataclasses model style, but not supported by WPS. + WPS601, +no-accept-encodings = True +nested-classes-whitelist=Config +inline-quotes = double diff --git a/.framework/python/charts/.helmignore b/.framework/python/charts/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/.framework/python/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/python/charts/Chart.yaml b/.framework/python/charts/Chart.yaml new file mode 100644 index 0000000..b2beb19 --- /dev/null +++ b/.framework/python/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/python/charts/templates/_helpers.yml b/.framework/python/charts/templates/_helpers.yml new file mode 100644 index 0000000..49515f2 --- /dev/null +++ b/.framework/python/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/python/charts/templates/anythink-backend-deployment.yaml b/.framework/python/charts/templates/anythink-backend-deployment.yaml new file mode 100644 index 0000000..3997ff5 --- /dev/null +++ b/.framework/python/charts/templates/anythink-backend-deployment.yaml @@ -0,0 +1,74 @@ +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 + - "poetry run uvicorn --host=0.0.0.0 --port={{ .Values.backend.containerPort }} app.main:app" + 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: + - command: + - sh + - -c + - "poetry run alembic upgrade head && ./seeds.sh" + 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/python/charts/templates/anythink-backend-service.yaml b/.framework/python/charts/templates/anythink-backend-service.yaml new file mode 100644 index 0000000..21bb516 --- /dev/null +++ b/.framework/python/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/python/charts/templates/anythink-frontend-deployment.yaml b/.framework/python/charts/templates/anythink-frontend-deployment.yaml new file mode 100644 index 0000000..f9be249 --- /dev/null +++ b/.framework/python/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/python/charts/templates/anythink-frontend-service.yaml b/.framework/python/charts/templates/anythink-frontend-service.yaml new file mode 100644 index 0000000..217f8c5 --- /dev/null +++ b/.framework/python/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/python/charts/templates/database-deployment.yaml b/.framework/python/charts/templates/database-deployment.yaml new file mode 100644 index 0000000..19752ee --- /dev/null +++ b/.framework/python/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/python/charts/templates/database-pvc.yaml b/.framework/python/charts/templates/database-pvc.yaml new file mode 100644 index 0000000..88517f3 --- /dev/null +++ b/.framework/python/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/python/charts/templates/database-service.yaml b/.framework/python/charts/templates/database-service.yaml new file mode 100644 index 0000000..80b47d3 --- /dev/null +++ b/.framework/python/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/python/charts/values.yaml b/.framework/python/charts/values.yaml new file mode 100644 index 0000000..0464a04 --- /dev/null +++ b/.framework/python/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/python/docker-compose.yml b/.framework/python/docker-compose.yml new file mode 100644 index 0000000..65aaee9 --- /dev/null +++ b/.framework/python/docker-compose.yml @@ -0,0 +1,67 @@ +version: "3.8" +services: + anythink-backend-python: + build: ./backend + container_name: anythink-backend-python + command: > + sh -c "cd backend && + poetry install && + poetry export -f "requirements.txt" --without-hashes --with-credentials > "requirements.txt" + /wait-for-it.sh postgres-python:5432 -q -t 60 && + poetry run alembic upgrade head && + poetry run gunicorn app.main:app --worker-class=uvicorn.workers.UvicornWorker --bind=0.0.0.0:3000 --workers=5" + working_dir: /usr/src + volumes: + - ./:/usr/src + ports: + - "3000:3000" + environment: + - APP_ENV=dev + - SECRET_KEY=secret + - DEBUG=True + - DATABASE_URL=postgresql://postgres:@postgres-python:5432/anythink-market + - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN} + depends_on: + - "postgres-python" + + anythink-frontend-react: + build: ./frontend + container_name: anythink-frontend-react + command: sh -c "cd frontend && yarn install && /wait-for-it.sh anythink-backend-python:3000 -t 120 --strict -- curl --head -X GET --retry 30 --retry-connrefused --retry-delay 1 anythink-backend-python: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-python" + + postgres-python: + container_name: postgres-python + 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: + image: public.ecr.aws/v0a2l7y2/wilco/anythink-ack:latest + container_name: anythink-ack + environment: + - GITHUB_TOKEN=$GITHUB_TOKEN + - CODESPACE_NAME=$CODESPACE_NAME + depends_on: + - "anythink-frontend-react" diff --git a/.framework/rails/.devcontainer/devcontainer.json b/.framework/rails/.devcontainer/devcontainer.json new file mode 100644 index 0000000..171e2d2 --- /dev/null +++ b/.framework/rails/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Anythink Development Container", + "image": "public.ecr.aws/v0a2l7y2/wilco/anythink-devcontainer:latest" +} diff --git a/.framework/rails/backend/.gitignore b/.framework/rails/backend/.gitignore new file mode 100644 index 0000000..e8245b7 --- /dev/null +++ b/.framework/rails/backend/.gitignore @@ -0,0 +1,30 @@ +# See https://help.github.com/items/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-journal + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore uploaded files in development +/storage/* + +/node_modules +/yarn-error.log + +/public/assets +.byebug_history + +# Ignore master key for decrypting credentials and more. +/config/master.key diff --git a/.framework/rails/backend/.ruby-version b/.framework/rails/backend/.ruby-version new file mode 100644 index 0000000..460b6fd --- /dev/null +++ b/.framework/rails/backend/.ruby-version @@ -0,0 +1 @@ +2.7.5 \ No newline at end of file diff --git a/.framework/rails/backend/Dockerfile b/.framework/rails/backend/Dockerfile new file mode 100644 index 0000000..b9d3ef3 --- /dev/null +++ b/.framework/rails/backend/Dockerfile @@ -0,0 +1 @@ +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-backend-rails:latest diff --git a/.framework/rails/backend/Dockerfile.aws b/.framework/rails/backend/Dockerfile.aws new file mode 100644 index 0000000..18fe3bb --- /dev/null +++ b/.framework/rails/backend/Dockerfile.aws @@ -0,0 +1,12 @@ +FROM ruby:2.7.5 + +RUN apt-get update -qq && apt-get install -y build-essential nodejs + +WORKDIR /usr/src +COPY backend ./backend +COPY .wilco ./.wilco + +# Pre-install gems +WORKDIR /usr/src/backend +RUN bundle install + diff --git a/.framework/rails/backend/Gemfile b/.framework/rails/backend/Gemfile new file mode 100644 index 0000000..2eca1e4 --- /dev/null +++ b/.framework/rails/backend/Gemfile @@ -0,0 +1,69 @@ +source 'https://rubygems.org' +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +ruby '2.7.5' + +# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' +gem 'rails', '~> 6.1.0' +# Use Puma as the app server +gem 'puma', '~> 3.11' + +# Faraday +gem 'faraday' + +# Use Uglifier as compressor for JavaScript assets +gem 'uglifier', '>= 1.3.0' +# See https://github.com/rails/execjs#readme for more supported runtimes +# gem 'mini_racer', platforms: :ruby + +# Use CoffeeScript for .coffee assets and views +gem 'coffee-rails', '~> 4.2' +# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks +gem 'turbolinks', '~> 5' +# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder +gem 'jbuilder', '~> 2.5' +# Use Redis adapter to run Action Cable in production +# gem 'redis', '~> 4.0' +# Use ActiveModel has_secure_password +# gem 'bcrypt', '~> 3.1.7' + +# Use ActiveStorage variant +# gem 'mini_magick', '~> 4.8' + +# Use Capistrano for deployment +# gem 'capistrano-rails', group: :development + +gem 'devise' +gem 'rack-cors' +gem 'acts-as-taggable-on', '~> 8.1.0' +gem 'jwt' +gem "acts_as_follower", github: "tcocca/acts_as_follower" +gem 'pg' + +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', '>= 1.1.0', require: false + +group :development, :test do + # Call 'byebug' anywhere in the code to stop execution and get a debugger console + gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] +end + +group :development do + # Access an interactive console on exception pages or by calling 'console' anywhere in the code. + gem 'web-console', '>= 3.3.0' + gem 'listen', '>= 3.0.5', '< 3.2' + # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' +end + +group :test do + # Adds support for Capybara system testing and selenium driver + gem 'capybara', '>= 2.15', '< 4.0' + gem 'selenium-webdriver' + # Easy installation and use of chromedriver to run system tests with Chrome + gem 'chromedriver-helper' +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] diff --git a/.framework/rails/backend/Gemfile.lock b/.framework/rails/backend/Gemfile.lock new file mode 100644 index 0000000..9e838c7 --- /dev/null +++ b/.framework/rails/backend/Gemfile.lock @@ -0,0 +1,258 @@ +GIT + remote: https://github.com/tcocca/acts_as_follower.git + revision: c5ac7b9601c4af01eb4d9112330b27be4d694ecc + specs: + acts_as_follower (0.2.1) + activerecord (>= 4.0) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (6.1.4.4) + actionpack (= 6.1.4.4) + activesupport (= 6.1.4.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.1.4.4) + actionpack (= 6.1.4.4) + activejob (= 6.1.4.4) + activerecord (= 6.1.4.4) + activestorage (= 6.1.4.4) + activesupport (= 6.1.4.4) + mail (>= 2.7.1) + actionmailer (6.1.4.4) + actionpack (= 6.1.4.4) + actionview (= 6.1.4.4) + activejob (= 6.1.4.4) + activesupport (= 6.1.4.4) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.1.4.4) + actionview (= 6.1.4.4) + activesupport (= 6.1.4.4) + rack (~> 2.0, >= 2.0.9) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.1.4.4) + actionpack (= 6.1.4.4) + activerecord (= 6.1.4.4) + activestorage (= 6.1.4.4) + activesupport (= 6.1.4.4) + nokogiri (>= 1.8.5) + actionview (6.1.4.4) + activesupport (= 6.1.4.4) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (6.1.4.4) + activesupport (= 6.1.4.4) + globalid (>= 0.3.6) + activemodel (6.1.4.4) + activesupport (= 6.1.4.4) + activerecord (6.1.4.4) + activemodel (= 6.1.4.4) + activesupport (= 6.1.4.4) + activestorage (6.1.4.4) + actionpack (= 6.1.4.4) + activejob (= 6.1.4.4) + activerecord (= 6.1.4.4) + activesupport (= 6.1.4.4) + marcel (~> 1.0.0) + mini_mime (>= 1.1.0) + activesupport (6.1.4.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + acts-as-taggable-on (8.1.0) + activerecord (>= 5.0, < 6.2) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + archive-zip (0.12.0) + io-like (~> 0.3.0) + bcrypt (3.1.16) + bindex (0.8.1) + bootsnap (1.7.5) + msgpack (~> 1.0) + builder (3.2.4) + byebug (11.1.3) + capybara (3.35.3) + addressable + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + childprocess (3.0.0) + chromedriver-helper (2.1.1) + archive-zip (~> 0.10) + nokogiri (~> 1.8) + coffee-rails (4.2.2) + coffee-script (>= 2.2.0) + railties (>= 4.0.0) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.12.2) + concurrent-ruby (1.1.9) + crass (1.0.6) + devise (4.8.1) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + erubi (1.10.0) + execjs (2.7.0) + faraday (2.1.0) + faraday-net_http (~> 2.0) + ruby2_keywords (>= 0.0.4) + faraday-net_http (2.0.1) + ffi (1.15.0) + globalid (1.0.0) + activesupport (>= 5.0) + i18n (1.8.11) + concurrent-ruby (~> 1.0) + io-like (0.3.1) + jbuilder (2.11.2) + activesupport (>= 5.0.0) + jwt (1.5.6) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) + loofah (2.13.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + mail (2.7.1) + mini_mime (>= 0.1.1) + marcel (1.0.2) + method_source (1.0.0) + mini_mime (1.1.0) + mini_portile2 (2.6.1) + minitest (5.15.0) + msgpack (1.4.2) + nio4r (2.5.8) + nokogiri (1.12.5) + mini_portile2 (~> 2.6.1) + racc (~> 1.4) + orm_adapter (0.5.0) + pg (1.2.3) + public_suffix (4.0.6) + puma (3.12.6) + racc (1.6.0) + rack (2.2.3) + rack-cors (1.1.1) + rack (>= 2.0.0) + rack-test (1.1.0) + rack (>= 1.0, < 3) + rails (6.1.4.4) + actioncable (= 6.1.4.4) + actionmailbox (= 6.1.4.4) + actionmailer (= 6.1.4.4) + actionpack (= 6.1.4.4) + actiontext (= 6.1.4.4) + actionview (= 6.1.4.4) + activejob (= 6.1.4.4) + activemodel (= 6.1.4.4) + activerecord (= 6.1.4.4) + activestorage (= 6.1.4.4) + activesupport (= 6.1.4.4) + bundler (>= 1.15.0) + railties (= 6.1.4.4) + sprockets-rails (>= 2.0.0) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.4.2) + loofah (~> 2.3) + railties (6.1.4.4) + actionpack (= 6.1.4.4) + activesupport (= 6.1.4.4) + method_source + rake (>= 0.13) + thor (~> 1.0) + rake (13.0.6) + rb-fsevent (0.10.4) + rb-inotify (0.10.1) + ffi (~> 1.0) + regexp_parser (2.1.1) + responders (3.0.1) + actionpack (>= 5.0) + railties (>= 5.0) + ruby2_keywords (0.0.5) + ruby_dep (1.5.0) + rubyzip (2.3.0) + selenium-webdriver (3.142.7) + childprocess (>= 0.5, < 4.0) + rubyzip (>= 1.2.2) + spring (2.1.1) + spring-watcher-listen (2.0.1) + listen (>= 2.7, < 4.0) + spring (>= 1.2, < 3.0) + sprockets (4.0.2) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) + thor (1.1.0) + turbolinks (5.2.1) + turbolinks-source (~> 5.2) + turbolinks-source (5.2.0) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + uglifier (4.2.0) + execjs (>= 0.3.0, < 3) + warden (1.2.9) + rack (>= 2.0.9) + web-console (4.2.0) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket-driver (0.7.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.5.2) + +PLATFORMS + ruby + +DEPENDENCIES + acts-as-taggable-on (~> 8.1.0) + acts_as_follower! + bootsnap (>= 1.1.0) + byebug + capybara (>= 2.15, < 4.0) + chromedriver-helper + coffee-rails (~> 4.2) + devise + faraday + jbuilder (~> 2.5) + jwt + listen (>= 3.0.5, < 3.2) + pg + puma (~> 3.11) + rack-cors + rails (~> 6.1.0) + selenium-webdriver + spring + spring-watcher-listen (~> 2.0.0) + turbolinks (~> 5) + tzinfo-data + uglifier (>= 1.3.0) + web-console (>= 3.3.0) + +RUBY VERSION + ruby 2.7.5p203 + +BUNDLED WITH + 2.1.4 diff --git a/.framework/rails/backend/README.md b/.framework/rails/backend/README.md new file mode 100644 index 0000000..f056f30 --- /dev/null +++ b/.framework/rails/backend/README.md @@ -0,0 +1,19 @@ +# Anythink Market Backend + +The Anythink Market backend is Ruby web app written with [Ruby On Rails](https://rubyonrails.org/) + +## Dependencies + +- [acts_as_follower](https://github.com/tcocca/acts_as_follower) - For implementing followers/following +- [acts_as_taggable](https://github.com/mbleigh/acts-as-taggable-on) - For implementing tagging functionality +- [Devise](https://github.com/plataformatec/devise) - For implementing authentication +- [Jbuilder](https://github.com/rails/jbuilder) - Default JSON rendering gem that ships with Rails, used for making reusable templates for JSON output. +- [JWT](https://github.com/jwt/ruby-jwt) - For generating and validating JWTs for authentication + +## Folders + +- `app/models` - Contains the database models for the application where we can define methods, validations, queries, and relations to other models. +- `app/views` - Contains templates for generating the JSON output for the API +- `app/controllers` - Contains the controllers where requests are routed to their actions, where we find and manipulate our models and return them for the views to render. +- `config` - Contains configuration files for our Rails application and for our database, along with an `initializers` folder for scripts that get run on boot. +- `db` - Contains the migrations needed to create our database schema. diff --git a/.framework/rails/backend/Rakefile b/.framework/rails/backend/Rakefile new file mode 100644 index 0000000..e85f913 --- /dev/null +++ b/.framework/rails/backend/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/.framework/rails/backend/app/assets/config/manifest.js b/.framework/rails/backend/app/assets/config/manifest.js new file mode 100644 index 0000000..b16e53d --- /dev/null +++ b/.framework/rails/backend/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../javascripts .js +//= link_directory ../stylesheets .css diff --git a/.framework/rails/backend/app/assets/images/.keep b/.framework/rails/backend/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/app/assets/javascripts/.keep b/.framework/rails/backend/app/assets/javascripts/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/app/assets/stylesheets/.keep b/.framework/rails/backend/app/assets/stylesheets/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/app/channels/application_cable/channel.rb b/.framework/rails/backend/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/.framework/rails/backend/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/.framework/rails/backend/app/channels/application_cable/connection.rb b/.framework/rails/backend/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..0ff5442 --- /dev/null +++ b/.framework/rails/backend/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/.framework/rails/backend/app/controllers/application_controller.rb b/.framework/rails/backend/app/controllers/application_controller.rb new file mode 100644 index 0000000..c45cfd2 --- /dev/null +++ b/.framework/rails/backend/app/controllers/application_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + before_action :configure_permitted_parameters, if: :devise_controller? + before_action :authenticate_user + + def root + render plain: "API server is up and running, please use the frontend app to interact with the system" + end + + def health + render plain: 'OK' + end + + private + + def configure_permitted_parameters + devise_parameter_sanitizer.permit(:sign_up, keys: [:username]) + end + + def authenticate_user + return if request.headers['Authorization'].blank? + + authenticate_or_request_with_http_token do |token| + begin + jwt_payload = JWT.decode(token, Rails.application.secrets.secret_key_base).first + + @current_user_id = jwt_payload['id'] + rescue JWT::ExpiredSignature, JWT::VerificationError, JWT::DecodeError + head :unauthorized + end + end + end + + def authenticate_user!(_options = {}) + head :unauthorized unless signed_in? + end + + def current_user + @current_user ||= super || User.find(@current_user_id) + end + + def signed_in? + @current_user_id.present? + end +end diff --git a/.framework/rails/backend/app/controllers/comments_controller.rb b/.framework/rails/backend/app/controllers/comments_controller.rb new file mode 100644 index 0000000..6bd39e7 --- /dev/null +++ b/.framework/rails/backend/app/controllers/comments_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CommentsController < ApplicationController + before_action :authenticate_user!, except: [:index] + before_action :find_item! + + def index + @comments = @item.comments.order(created_at: :desc) + end + + def create + @comment = @item.comments.new(comment_params) + @comment.user = current_user + + render json: { errors: @comment.errors }, status: :unprocessable_entity unless @comment.save + end + + def destroy + @comment = @item.comments.find(params[:id]) + + @comment.destroy + render json: {} + end + + private + + def comment_params + params.require(:comment).permit(:body) + end + + def find_item! + @item = Item.find_by!(slug: params[:item_slug]) + end +end diff --git a/.framework/rails/backend/app/controllers/concerns/.keep b/.framework/rails/backend/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/app/controllers/favorites_controller.rb b/.framework/rails/backend/app/controllers/favorites_controller.rb new file mode 100644 index 0000000..191d138 --- /dev/null +++ b/.framework/rails/backend/app/controllers/favorites_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class FavoritesController < ApplicationController + before_action :authenticate_user! + before_action :find_item! + + def create + current_user.favorite(@item) + + render 'items/show' + end + + def destroy + current_user.unfavorite(@item) + + render 'items/show' + end + + private + + def find_item! + @item = Item.find_by!(slug: params[:item_slug]) + end +end diff --git a/.framework/rails/backend/app/controllers/follows_controller.rb b/.framework/rails/backend/app/controllers/follows_controller.rb new file mode 100644 index 0000000..fa6f674 --- /dev/null +++ b/.framework/rails/backend/app/controllers/follows_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class FollowsController < ApplicationController + before_action :authenticate_user! + + def create + @user = User.find_by!(username: params[:profile_username]) + + current_user.follow(@user) if current_user.id != @user.id + + render 'profiles/show' + end + + def destroy + @user = User.find_by!(username: params[:profile_username]) + + current_user.stop_following(@user) if current_user.id != @user.id + + render 'profiles/show' + end +end diff --git a/.framework/rails/backend/app/controllers/items_controller.rb b/.framework/rails/backend/app/controllers/items_controller.rb new file mode 100644 index 0000000..0e92499 --- /dev/null +++ b/.framework/rails/backend/app/controllers/items_controller.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true +require_relative "../../lib/event" +include Event + +class ItemsController < ApplicationController + before_action :authenticate_user!, except: %i[index show] + + def index + @items = Item.includes(:tags) + + @items = @items.tagged_with(params[:tag]) if params[:tag].present? + @items = @items.sellered_by(params[:seller]) if params[:seller].present? + @items = @items.favorited_by(params[:favorited]) if params[:favorited].present? + + @items_count = @items.count + + @items = @items.order(created_at: :desc).offset(params[:offset] || 0).limit(params[:limit] || 100) + + render json: { + items: @items.map { |item| + { + title: item.title, + slug: item.slug, + description: item.description, + image: item.image, + tagList: item.tags.map(&:name), + createdAt: item.created_at, + updatedAt: item.updated_at, + seller: { + username: item.user.username, + bio: item.user.bio, + image: item.user.image || 'https://static.productionready.io/images/smiley-cyrus.jpg', + following: signed_in? ? current_user.following?(item.user) : false, + }, + favorited: signed_in? ? current_user.favorited?(item) : false, + favoritesCount: item.favorites_count || 0 + } + }, + items_count: @items_count + } + end + + def feed + @items = Item.includes(:user).where(user: current_user.following_users) + + @items_count = @items.count + + @items = @items.order(created_at: :asc).offset(params[:offset] || 0).limit(params[:limit] || 20) + + render :index + end + + def create + @item = Item.new(item_params) + @item.user = current_user + + if @item.save + sendEvent("item_created", { item: item_params }) + render :show + else + render json: { errors: @item.errors }, status: :unprocessable_entity + end + end + + def show + @item = Item.find_by!(slug: params[:slug]) + end + + def update + @item = Item.find_by!(slug: params[:slug]) + + if @item.user_id == @current_user_id + @item.update(item_params) + + render :show + else + render json: { errors: { item: ['not owned by user'] } }, status: :forbidden + end + end + + def destroy + @item = Item.find_by!(slug: params[:slug]) + + if @item.user_id == @current_user_id + @item.destroy + + render json: {} + else + render json: { errors: { item: ['not owned by user'] } }, status: :forbidden + end + end + + private + + def item_params + params.require(:item).permit(:title, :description, :image, tag_list: []) + end +end diff --git a/.framework/rails/backend/app/controllers/ping_controller.rb b/.framework/rails/backend/app/controllers/ping_controller.rb new file mode 100644 index 0000000..4d72c28 --- /dev/null +++ b/.framework/rails/backend/app/controllers/ping_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +include Faraday +require_relative "../../lib/event" +include Event + +class PingController < ApplicationController + def index + response = sendEvent("ping", nil) + render json: response.body + end +end diff --git a/.framework/rails/backend/app/controllers/profiles_controller.rb b/.framework/rails/backend/app/controllers/profiles_controller.rb new file mode 100644 index 0000000..95898c8 --- /dev/null +++ b/.framework/rails/backend/app/controllers/profiles_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ProfilesController < ApplicationController + def show + @user = User.find_by(username: params[:username]) + end +end diff --git a/.framework/rails/backend/app/controllers/registrations_controller.rb b/.framework/rails/backend/app/controllers/registrations_controller.rb new file mode 100644 index 0000000..9a8f8e4 --- /dev/null +++ b/.framework/rails/backend/app/controllers/registrations_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative "../../lib/event" +include Event + +class RegistrationsController < Devise::RegistrationsController + def create + super + + if @user.persisted? + sendEvent("user_created", { username: @user.username }) + end + end +end diff --git a/.framework/rails/backend/app/controllers/sessions_controller.rb b/.framework/rails/backend/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..f2cdf07 --- /dev/null +++ b/.framework/rails/backend/app/controllers/sessions_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SessionsController < Devise::SessionsController + def create + user = User.find_by(email: sign_in_params[:email]) + + if user && user.valid_password?(sign_in_params[:password]) + @current_user = user + else + render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity + end + end +end diff --git a/.framework/rails/backend/app/controllers/tags_controller.rb b/.framework/rails/backend/app/controllers/tags_controller.rb new file mode 100644 index 0000000..7d73a96 --- /dev/null +++ b/.framework/rails/backend/app/controllers/tags_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TagsController < ApplicationController + def index + render json: { tags: Item.tag_counts.most_used.map(&:name) } + end +end diff --git a/.framework/rails/backend/app/controllers/users_controller.rb b/.framework/rails/backend/app/controllers/users_controller.rb new file mode 100644 index 0000000..c98f170 --- /dev/null +++ b/.framework/rails/backend/app/controllers/users_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class UsersController < ApplicationController + before_action :authenticate_user! + + def show; end + + def update + if current_user.update(user_params) + render :show + else + render json: { errors: current_user.errors }, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:username, :email, :password, :bio, :image) + end +end diff --git a/.framework/rails/backend/app/helpers/application_helper.rb b/.framework/rails/backend/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/.framework/rails/backend/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/.framework/rails/backend/app/jobs/application_job.rb b/.framework/rails/backend/app/jobs/application_job.rb new file mode 100644 index 0000000..a009ace --- /dev/null +++ b/.framework/rails/backend/app/jobs/application_job.rb @@ -0,0 +1,2 @@ +class ApplicationJob < ActiveJob::Base +end diff --git a/.framework/rails/backend/app/mailers/application_mailer.rb b/.framework/rails/backend/app/mailers/application_mailer.rb new file mode 100644 index 0000000..286b223 --- /dev/null +++ b/.framework/rails/backend/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' +end diff --git a/.framework/rails/backend/app/models/application_record.rb b/.framework/rails/backend/app/models/application_record.rb new file mode 100644 index 0000000..10a4cba --- /dev/null +++ b/.framework/rails/backend/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/.framework/rails/backend/app/models/comment.rb b/.framework/rails/backend/app/models/comment.rb new file mode 100644 index 0000000..75d76cf --- /dev/null +++ b/.framework/rails/backend/app/models/comment.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Comment < ApplicationRecord + belongs_to :user + belongs_to :item + + validates :body, presence: true, allow_blank: false +end diff --git a/.framework/rails/backend/app/models/concerns/.keep b/.framework/rails/backend/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/app/models/favorite.rb b/.framework/rails/backend/app/models/favorite.rb new file mode 100644 index 0000000..afc2a4c --- /dev/null +++ b/.framework/rails/backend/app/models/favorite.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Favorite < ApplicationRecord + belongs_to :user + belongs_to :item, counter_cache: true +end diff --git a/.framework/rails/backend/app/models/follow.rb b/.framework/rails/backend/app/models/follow.rb new file mode 100644 index 0000000..0746982 --- /dev/null +++ b/.framework/rails/backend/app/models/follow.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Follow < ApplicationRecord + extend ActsAsFollower::FollowerLib + extend ActsAsFollower::FollowScopes + + # NOTE: Follows belong to the "followable" interface, and also to followers + belongs_to :followable, polymorphic: true + belongs_to :follower, polymorphic: true + + def block! + update_attribute(:blocked, true) + end +end diff --git a/.framework/rails/backend/app/models/item.rb b/.framework/rails/backend/app/models/item.rb new file mode 100644 index 0000000..b03d86e --- /dev/null +++ b/.framework/rails/backend/app/models/item.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Item < ApplicationRecord + belongs_to :user + has_many :favorites, dependent: :destroy + has_many :comments, dependent: :destroy + + scope :sellered_by, ->(username) { where(user: User.where(username: username)) } + scope :favorited_by, ->(username) { joins(:favorites).where(favorites: { user: User.where(username: username) }) } + + acts_as_taggable + + validates :title, presence: true, allow_blank: false + validates :description, presence: true, allow_blank: false + validates :slug, uniqueness: true, exclusion: { in: ['feed'] } + + before_validation do + self.slug ||= "#{title.to_s.parameterize}-#{rand(36**6).to_s(36)}" + end +end diff --git a/.framework/rails/backend/app/models/user.rb b/.framework/rails/backend/app/models/user.rb new file mode 100644 index 0000000..1011b20 --- /dev/null +++ b/.framework/rails/backend/app/models/user.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class User < ApplicationRecord + # Include default devise modules. Others available are: + # :confirmable, :lockable, :timeoutable and :omniauthable + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :trackable, :validatable + + has_many :items, dependent: :destroy + has_many :favorites, dependent: :destroy + has_many :comments, dependent: :destroy + + acts_as_follower + acts_as_followable + + validates :username, uniqueness: { case_sensitive: true }, + format: { with: /\A[a-zA-Z0-9]+\z/ }, + presence: true, + allow_blank: false + + def generate_jwt + JWT.encode({ id: id, + exp: 60.days.from_now.to_i }, + Rails.application.secrets.secret_key_base) + end + + def favorite(item) + favorites.find_or_create_by(item: item) + end + + def unfavorite(item) + favorites.where(item: item).destroy_all + + item.reload + end + + def favorited?(item) + favorites.find_by(item_id: item.id).present? + end +end diff --git a/.framework/rails/backend/app/views/comments/_comment.json.jbuilder b/.framework/rails/backend/app/views/comments/_comment.json.jbuilder new file mode 100644 index 0000000..9fdf442 --- /dev/null +++ b/.framework/rails/backend/app/views/comments/_comment.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +json.call(comment, :id, :body) +json.createdAt comment.created_at +json.updatedAt comment.updated_at +json.seller comment.user, partial: 'profiles/profile', as: :user diff --git a/.framework/rails/backend/app/views/comments/create.json.jbuilder b/.framework/rails/backend/app/views/comments/create.json.jbuilder new file mode 100644 index 0000000..e9773ab --- /dev/null +++ b/.framework/rails/backend/app/views/comments/create.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.comment do |json| + json.partial! 'comments/comment', comment: @comment +end diff --git a/.framework/rails/backend/app/views/comments/index.json.jbuilder b/.framework/rails/backend/app/views/comments/index.json.jbuilder new file mode 100644 index 0000000..502bea2 --- /dev/null +++ b/.framework/rails/backend/app/views/comments/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.comments do |json| + json.array! @comments, partial: 'comments/comment', as: :comment +end diff --git a/.framework/rails/backend/app/views/devise/registrations/create.json.jbuilder b/.framework/rails/backend/app/views/devise/registrations/create.json.jbuilder new file mode 100644 index 0000000..60f1da9 --- /dev/null +++ b/.framework/rails/backend/app/views/devise/registrations/create.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.user do |json| + json.partial! 'users/user', user: current_user +end diff --git a/.framework/rails/backend/app/views/devise/sessions/create.json.jbuilder b/.framework/rails/backend/app/views/devise/sessions/create.json.jbuilder new file mode 100644 index 0000000..60f1da9 --- /dev/null +++ b/.framework/rails/backend/app/views/devise/sessions/create.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.user do |json| + json.partial! 'users/user', user: current_user +end diff --git a/.framework/rails/backend/app/views/items/_item.json.jbuilder b/.framework/rails/backend/app/views/items/_item.json.jbuilder new file mode 100644 index 0000000..183b449 --- /dev/null +++ b/.framework/rails/backend/app/views/items/_item.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.call(item, :title, :slug, :description, :image) +json.createdAt item.created_at +json.updatedAt item.updated_at +json.tagList item.tag_list +json.seller item.user, partial: 'profiles/profile', as: :user +json.favorited signed_in? ? current_user.favorited?(item) : false +json.favoritesCount item.favorites_count || 0 diff --git a/.framework/rails/backend/app/views/items/index.json.jbuilder b/.framework/rails/backend/app/views/items/index.json.jbuilder new file mode 100644 index 0000000..3d644dd --- /dev/null +++ b/.framework/rails/backend/app/views/items/index.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.items do |json| + json.array! @items, partial: 'items/item', as: :item +end + +json.items_count @items_count diff --git a/.framework/rails/backend/app/views/items/show.json.jbuilder b/.framework/rails/backend/app/views/items/show.json.jbuilder new file mode 100644 index 0000000..2d84564 --- /dev/null +++ b/.framework/rails/backend/app/views/items/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.item do |json| + json.partial! 'items/item', item: @item +end diff --git a/.framework/rails/backend/app/views/profiles/_profile.json.jbuilder b/.framework/rails/backend/app/views/profiles/_profile.json.jbuilder new file mode 100644 index 0000000..ded3482 --- /dev/null +++ b/.framework/rails/backend/app/views/profiles/_profile.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.call(user, :username, :bio) +json.image user.image || 'https://static.productionready.io/images/smiley-cyrus.jpg' +json.following signed_in? ? current_user.following?(user) : false diff --git a/.framework/rails/backend/app/views/profiles/show.json.jbuilder b/.framework/rails/backend/app/views/profiles/show.json.jbuilder new file mode 100644 index 0000000..887ba8e --- /dev/null +++ b/.framework/rails/backend/app/views/profiles/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.profile do |json| + json.partial! 'profiles/profile', user: @user +end diff --git a/.framework/rails/backend/app/views/users/_user.json.jbuilder b/.framework/rails/backend/app/views/users/_user.json.jbuilder new file mode 100644 index 0000000..a7ff346 --- /dev/null +++ b/.framework/rails/backend/app/views/users/_user.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.call(user, :id, :email, :username, :bio, :image) +json.token user.generate_jwt diff --git a/.framework/rails/backend/app/views/users/show.json.jbuilder b/.framework/rails/backend/app/views/users/show.json.jbuilder new file mode 100644 index 0000000..60f1da9 --- /dev/null +++ b/.framework/rails/backend/app/views/users/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.user do |json| + json.partial! 'users/user', user: current_user +end diff --git a/.framework/rails/backend/bin/bundle b/.framework/rails/backend/bin/bundle new file mode 100755 index 0000000..f19acf5 --- /dev/null +++ b/.framework/rails/backend/bin/bundle @@ -0,0 +1,3 @@ +#!/usr/bin/env ruby +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +load Gem.bin_path('bundler', 'bundle') diff --git a/.framework/rails/backend/bin/heroku_release.sh b/.framework/rails/backend/bin/heroku_release.sh new file mode 100755 index 0000000..35fae48 --- /dev/null +++ b/.framework/rails/backend/bin/heroku_release.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# Usage: bin/heroku_deploy + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NO_COLOR='\033[0m' + +set -euo pipefail + +schema_version=$(bin/rails db:version | { grep "^Current version: [0-9]\\+$" || true; } | tr -s ' ' | cut -d ' ' -f3) + +if [ -z "$schema_version" ]; then + printf "\n⏳${YELLOW} [Release Phase]: Seeding db from scratch.${NO_COLOR}\n" + bin/rails db:init + bin/rails db:migrate + bin/rails db:seed +elif [ "$schema_version" -eq "0" ]; then + printf "\n⏳${YELLOW} [Release Phase]: Loading the database schema.${NO_COLOR}\n" + bin/rails db:schema:load + bin/rails db:seed +fi + +printf "\n🎉${GREEN} [Release Phase]: Database is up to date.${NO_COLOR}\n" \ No newline at end of file diff --git a/.framework/rails/backend/bin/rails b/.framework/rails/backend/bin/rails new file mode 100755 index 0000000..5badb2f --- /dev/null +++ b/.framework/rails/backend/bin/rails @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +begin + load File.expand_path('../spring', __FILE__) +rescue LoadError => e + raise unless e.message.include?('spring') +end +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/.framework/rails/backend/bin/rake b/.framework/rails/backend/bin/rake new file mode 100755 index 0000000..d87d5f5 --- /dev/null +++ b/.framework/rails/backend/bin/rake @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +begin + load File.expand_path('../spring', __FILE__) +rescue LoadError => e + raise unless e.message.include?('spring') +end +require_relative '../config/boot' +require 'rake' +Rake.application.run diff --git a/.framework/rails/backend/bin/setup b/.framework/rails/backend/bin/setup new file mode 100755 index 0000000..94fd4d7 --- /dev/null +++ b/.framework/rails/backend/bin/setup @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +require 'fileutils' +include FileUtils + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +chdir APP_ROOT do + # This script is a starting point to setup your application. + # Add necessary setup steps to this file. + + puts '== Installing dependencies ==' + system! 'gem install bundler --conservative' + system('bundle check') || system!('bundle install') + + # Install JavaScript dependencies if using Yarn + # system('bin/yarn') + + # puts "\n== Copying sample files ==" + # unless File.exist?('config/database.yml') + # cp 'config/database.yml.sample', 'config/database.yml' + # end + + puts "\n== Preparing database ==" + system! 'bin/rails db:setup' + + puts "\n== Removing old logs and tempfiles ==" + system! 'bin/rails log:clear tmp:clear' + + puts "\n== Restarting application server ==" + system! 'bin/rails restart' +end diff --git a/.framework/rails/backend/bin/spring b/.framework/rails/backend/bin/spring new file mode 100755 index 0000000..d89ee49 --- /dev/null +++ b/.framework/rails/backend/bin/spring @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +# This file loads Spring without using Bundler, in order to be fast. +# It gets overwritten when you run the `spring binstub` command. + +unless defined?(Spring) + require 'rubygems' + require 'bundler' + + lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) + spring = lockfile.specs.detect { |spec| spec.name == 'spring' } + if spring + Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path + gem 'spring', spring.version + require 'spring/binstub' + end +end diff --git a/.framework/rails/backend/bin/update b/.framework/rails/backend/bin/update new file mode 100755 index 0000000..58bfaed --- /dev/null +++ b/.framework/rails/backend/bin/update @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +require 'fileutils' +include FileUtils + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +chdir APP_ROOT do + # This script is a way to update your development environment automatically. + # Add necessary update steps to this file. + + puts '== Installing dependencies ==' + system! 'gem install bundler --conservative' + system('bundle check') || system!('bundle install') + + # Install JavaScript dependencies if using Yarn + # system('bin/yarn') + + puts "\n== Updating database ==" + system! 'bin/rails db:migrate' + + puts "\n== Removing old logs and tempfiles ==" + system! 'bin/rails log:clear tmp:clear' + + puts "\n== Restarting application server ==" + system! 'bin/rails restart' +end diff --git a/.framework/rails/backend/bin/yarn b/.framework/rails/backend/bin/yarn new file mode 100755 index 0000000..460dd56 --- /dev/null +++ b/.framework/rails/backend/bin/yarn @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + begin + exec "yarnpkg", *ARGV + rescue Errno::ENOENT + $stderr.puts "Yarn executable was not detected in the system." + $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" + exit 1 + end +end diff --git a/.framework/rails/backend/config.ru b/.framework/rails/backend/config.ru new file mode 100644 index 0000000..f7ba0b5 --- /dev/null +++ b/.framework/rails/backend/config.ru @@ -0,0 +1,5 @@ +# This file is used by Rack-based servers to start the application. + +require_relative 'config/environment' + +run Rails.application diff --git a/.framework/rails/backend/config/application.rb b/.framework/rails/backend/config/application.rb new file mode 100644 index 0000000..75f6674 --- /dev/null +++ b/.framework/rails/backend/config/application.rb @@ -0,0 +1,22 @@ +require_relative 'boot' + +require 'rails/all' + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module AnythinkMarket + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 5.2 + config.api_only = true + + # Allow all hosts + config.hosts = nil + + config.to_prepare do + DeviseController.respond_to :html, :json + end + end +end diff --git a/.framework/rails/backend/config/boot.rb b/.framework/rails/backend/config/boot.rb new file mode 100644 index 0000000..b9e460c --- /dev/null +++ b/.framework/rails/backend/config/boot.rb @@ -0,0 +1,4 @@ +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) + +require 'bundler/setup' # Set up gems listed in the Gemfile. +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/.framework/rails/backend/config/cable.yml b/.framework/rails/backend/config/cable.yml new file mode 100644 index 0000000..2dfc57c --- /dev/null +++ b/.framework/rails/backend/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: async + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: AnythinkMarket_production diff --git a/.framework/rails/backend/config/credentials.yml.enc b/.framework/rails/backend/config/credentials.yml.enc new file mode 100644 index 0000000..020476e --- /dev/null +++ b/.framework/rails/backend/config/credentials.yml.enc @@ -0,0 +1 @@ +QAFQjUBh2bXf1g8q1zIUvSj39taFJML8JgK9OcL4vFBXG+N4F3lLlrG8N8Fjdzw34be/TgFRlrk5VkTYaf7jdcmL1OeFPK/Ny4/8In/PKg3LK319iY3ths+5rhBQzJqFxicmOg6VI/E6H1h3evxrKFEdcegN9F+Zf7U9bBmHgiWbD7FQemox/21EzFeyq+z3IiVlTB4OlEg9WT5GF5UPkeh2vT7LfCPLxb/yH9UntxcrJvkNzW3bgqXUhKzuT6dClpTpieHGJIEcQNGfMOhtu58SLX86/utPKBY+olX3cY7bwlReDYGxZECCruf5IL+UFg8bQM99TQZwwvlGpi+4HDHD4Rmkff4VGSgxNAOT7Uk4OiobK7mHbR+Wj78/48TEAGkGTr40AxDg4NVLegtLx81TZEejkH5tp1XF--BCh73RGeAwVePiSo--s/TsxgL/nFnzGqAYtDOVxA== \ No newline at end of file diff --git a/.framework/rails/backend/config/database.yml b/.framework/rails/backend/config/database.yml new file mode 100644 index 0000000..ce77d59 --- /dev/null +++ b/.framework/rails/backend/config/database.yml @@ -0,0 +1,21 @@ +default: &default + adapter: postgresql + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + url: <%= ENV['DATABASE_URL'] %> + +development: + <<: *default + encoding: unicode + pool: 5 + +test: + <<: *default + +production: + <<: *default + adapter: postgresql + encoding: unicode + pool: 5 + prepared_statements: false + advisory_locks: false diff --git a/.framework/rails/backend/config/environment.rb b/.framework/rails/backend/config/environment.rb new file mode 100644 index 0000000..426333b --- /dev/null +++ b/.framework/rails/backend/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/.framework/rails/backend/config/environments/development.rb b/.framework/rails/backend/config/environments/development.rb new file mode 100644 index 0000000..7e6af8d --- /dev/null +++ b/.framework/rails/backend/config/environments/development.rb @@ -0,0 +1,63 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded on + # every request. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join('tmp', 'caching-dev.txt').exist? + config.action_controller.perform_caching = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options) + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Debug mode disables concatenation and preprocessing of assets. + # This option may cause significant delays in view rendering with a large + # number of complex assets. + config.assets.debug = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations + # config.action_view.raise_on_missing_translations = true + + # Use an evented file watcher to asynchronously detect changes in source code, + # routes, locales, etc. This feature depends on the listen gem. + config.file_watcher = ActiveSupport::EventedFileUpdateChecker + + config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } +end diff --git a/.framework/rails/backend/config/environments/production.rb b/.framework/rails/backend/config/environments/production.rb new file mode 100644 index 0000000..a9103c9 --- /dev/null +++ b/.framework/rails/backend/config/environments/production.rb @@ -0,0 +1,93 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.cache_classes = true + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from the `/public` folder by default since + # Apache or NGINX already handles this. + config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? + + # Compress JavaScripts and CSS. + config.assets.js_compressor = :uglifier + + # Do not fallback to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.action_controller.asset_host = 'http://assets.example.com' + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options) + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain + # config.action_cable.mount_path = nil + # config.action_cable.url = 'wss://example.com/cable' + # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = :debug + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment) + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "AnythinkMarket_#{Rails.env}" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Send deprecation notices to registered listeners. + config.active_support.deprecation = :notify + + # Use default logging formatter so that PID and timestamp are not suppressed. + config.log_formatter = ::Logger::Formatter.new + + # Use a different logger for distributed setups. + # require 'syslog/logger' + # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') + + if ENV["RAILS_LOG_TO_STDOUT"].present? + logger = ActiveSupport::Logger.new(STDOUT) + logger.formatter = config.log_formatter + config.logger = ActiveSupport::TaggedLogging.new(logger) + end + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false +end diff --git a/.framework/rails/backend/config/environments/test.rb b/.framework/rails/backend/config/environments/test.rb new file mode 100644 index 0000000..0a38fd3 --- /dev/null +++ b/.framework/rails/backend/config/environments/test.rb @@ -0,0 +1,46 @@ +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # The test environment is used exclusively to run your application's + # test suite. You never need to work with it otherwise. Remember that + # your test database is "scratch space" for the test suite and is wiped + # and recreated between test runs. Don't rely on the data there! + config.cache_classes = true + + # Do not eager load code on boot. This avoids loading your whole application + # just for the purpose of running a single test. If you are using a tool that + # preloads Rails for running tests, you may have to set it to true. + config.eager_load = false + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Raise exceptions instead of rendering exception templates. + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations + # config.action_view.raise_on_missing_translations = true +end diff --git a/.framework/rails/backend/config/initializers/application_controller_renderer.rb b/.framework/rails/backend/config/initializers/application_controller_renderer.rb new file mode 100644 index 0000000..89d2efa --- /dev/null +++ b/.framework/rails/backend/config/initializers/application_controller_renderer.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# ActiveSupport::Reloader.to_prepare do +# ApplicationController.renderer.defaults.merge!( +# http_host: 'example.org', +# https: false +# ) +# end diff --git a/.framework/rails/backend/config/initializers/assets.rb b/.framework/rails/backend/config/initializers/assets.rb new file mode 100644 index 0000000..4b828e8 --- /dev/null +++ b/.framework/rails/backend/config/initializers/assets.rb @@ -0,0 +1,14 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = '1.0' + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path +# Add Yarn node_modules folder to the asset load path. +Rails.application.config.assets.paths << Rails.root.join('node_modules') + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/.framework/rails/backend/config/initializers/backtrace_silencers.rb b/.framework/rails/backend/config/initializers/backtrace_silencers.rb new file mode 100644 index 0000000..59385cd --- /dev/null +++ b/.framework/rails/backend/config/initializers/backtrace_silencers.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. +# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } + +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. +# Rails.backtrace_cleaner.remove_silencers! diff --git a/.framework/rails/backend/config/initializers/content_security_policy.rb b/.framework/rails/backend/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..d3bcaa5 --- /dev/null +++ b/.framework/rails/backend/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy +# For further information see the following documentation +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + +# Rails.application.config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https + +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end + +# If you are using UJS then enable automatic nonce generation +# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } + +# Report CSP violations to a specified URI +# For further information see the following documentation: +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only +# Rails.application.config.content_security_policy_report_only = true diff --git a/.framework/rails/backend/config/initializers/cookies_serializer.rb b/.framework/rails/backend/config/initializers/cookies_serializer.rb new file mode 100644 index 0000000..5a6a32d --- /dev/null +++ b/.framework/rails/backend/config/initializers/cookies_serializer.rb @@ -0,0 +1,5 @@ +# Be sure to restart your server when you modify this file. + +# Specify a serializer for the signed and encrypted cookie jars. +# Valid options are :json, :marshal, and :hybrid. +Rails.application.config.action_dispatch.cookies_serializer = :json diff --git a/.framework/rails/backend/config/initializers/cors.rb b/.framework/rails/backend/config/initializers/cors.rb new file mode 100644 index 0000000..b745aba --- /dev/null +++ b/.framework/rails/backend/config/initializers/cors.rb @@ -0,0 +1,9 @@ +Rails.application.config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do + allow do + origins '*' + + resource '*', + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head] + end +end diff --git a/.framework/rails/backend/config/initializers/devise.rb b/.framework/rails/backend/config/initializers/devise.rb new file mode 100644 index 0000000..002356b --- /dev/null +++ b/.framework/rails/backend/config/initializers/devise.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# +# Use this hook to configure devise mailer, warden hooks and so forth. +# Many of these configuration options can be set straight in your model. +Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # confirmation, reset password and unlock tokens in the database. + # Devise will use the `secret_key_base` as its `secret_key` + # by default. You can change it below and use your own secret key. + # config.secret_key = 'eca6e8be56768ce1455d5a43892b0db1144c4015b2417aac99712a9f7df34677e212942a0c4e8f8768b6b9458e0b453774e0c2a9ac3f600b5e78842e725be180' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + + # ==> Mailer Configuration + # Configure the e-mail address which will be shown in Devise::Mailer, + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. + config.mailer_sender = 'hello@anythink.family' + + # Configure the class responsible to send e-mails. + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require 'devise/orm/active_record' + + # ==> Configuration for any authentication mechanism + # Configure which keys are used when authenticating a user. The default is + # just :email. You can configure it to use [:username, :subdomain], so for + # authenticating a user, both parameters are required. Remember that those + # parameters are used only when authenticating and not when retrieving from + # session. If you need permissions, you should implement that in a before filter. + # You can also supply a hash where the value is a boolean determining whether + # or not authentication should be aborted when the value is not present. + # config.authentication_keys = [:email] + + # Configure parameters from the request object used for authentication. Each entry + # given should be a request method and it will automatically be passed to the + # find_for_authentication method and considered in your model lookup. For instance, + # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. + # The same considerations mentioned for authentication_keys also apply to request_keys. + # config.request_keys = [] + + # Configure which authentication keys should be case-insensitive. + # These keys will be downcased upon creating or modifying a user and when used + # to authenticate or find a user. Default is :email. + config.case_insensitive_keys = [:email] + + # Configure which authentication keys should have whitespace stripped. + # These keys will have whitespace before and after removed upon creating or + # modifying a user and when used to authenticate or find a user. Default is :email. + config.strip_whitespace_keys = [:email] + + # Tell if authentication through request.params is enabled. True by default. + # It can be set to an array that will enable params authentication only for the + # given strategies, for example, `config.params_authenticatable = [:database]` will + # enable it only for database (email + password) authentication. + # config.params_authenticatable = true + + # Tell if authentication through HTTP Auth is enabled. False by default. + # It can be set to an array that will enable http authentication only for the + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: + # :database = Support basic authentication with authentication key + password + # config.http_authenticatable = false + + # If 401 status code should be returned for AJAX requests. True by default. + # config.http_authenticatable_on_xhr = true + + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' + + # It will change confirmation, password recovery and other workflows + # to behave the same regardless if the e-mail provided was right or wrong. + # Does not affect registerable. + # config.paranoid = true + + # By default Devise will store the user in session. You can skip storage for + # particular strategies by setting this option. + # Notice that if you are skipping storage for all authentication paths, you + # may want to disable generating routes to Devise's sessions controller by + # passing skip: :sessions to `devise_for` in your config/routes.rb + config.skip_session_storage = [:http_auth] + + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + + # ==> Configuration for :database_authenticatable + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. + # + # Limiting the stretches to just one in testing will increase the performance of + # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 + + # Set up a pepper to generate the hashed password. + # config.pepper = 'b278b1d480365dd815e58307e9c2cc160350cc20c62ca4a280be2a73b2a90f645a4f461a3278a45484073e0e3830cbbd1e133d7bbaf23e1dd9d580318b6c1876' + + # Send a notification to the original email when the user's email is changed. + # config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + # config.send_password_change_notification = false + + # ==> Configuration for :confirmable + # A period that the user is allowed to access the website even without + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + # config.allow_unconfirmed_access_for = 2.days + + # A period that the user is allowed to confirm their account before their + # token becomes invalid. For example, if set to 3.days, the user can confirm + # their account within 3 days after the mail was sent, but on the fourth day + # their account can't be confirmed with the token any more. + # Default is nil, meaning there is no restriction on how long a user can take + # before confirming their account. + # config.confirm_within = 3.days + + # If true, requires any email changes to be confirmed (exactly the same way as + # initial account confirmation) to be applied. Requires additional unconfirmed_email + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + config.reconfirmable = true + + # Defines which key will be used when confirming an account + # config.confirmation_keys = [:email] + + # ==> Configuration for :rememberable + # The time the user will be remembered without asking for credentials again. + # config.remember_for = 2.weeks + + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + + # If true, extends the user's remember period when remembered via cookie. + # config.extend_remember_period = false + + # Options to be passed to the created cookie. For instance, you can set + # secure: true in order to force SSL only cookies. + # config.rememberable_options = {} + + # ==> Configuration for :validatable + # Range for password length. + config.password_length = 6..128 + + # Email regex used to validate email formats. It simply asserts that + # one (and only one) @ exists in the given string. This is mainly + # to give user feedback and not to assert the e-mail validity. + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + + # ==> Configuration for :timeoutable + # The time you want to timeout the user session without activity. After this + # time the user will be asked for credentials again. Default is 30 minutes. + # config.timeout_in = 30.minutes + + # ==> Configuration for :lockable + # Defines which strategy will be used to lock an account. + # :failed_attempts = Locks an account after a number of failed attempts to sign in. + # :none = No lock strategy. You should handle locking by yourself. + # config.lock_strategy = :failed_attempts + + # Defines which key will be used when locking and unlocking an account + # config.unlock_keys = [:email] + + # Defines which strategy will be used to unlock an account. + # :email = Sends an unlock link to the user email + # :time = Re-enables login after a certain amount of time (see :unlock_in below) + # :both = Enables both strategies + # :none = No unlock strategy. You should handle unlocking by yourself. + # config.unlock_strategy = :both + + # Number of authentication tries before locking an account if lock_strategy + # is failed attempts. + # config.maximum_attempts = 20 + + # Time interval to unlock the account if :time is enabled as unlock_strategy. + # config.unlock_in = 1.hour + + # Warn on the last attempt before the account is locked. + # config.last_attempt_warning = true + + # ==> Configuration for :recoverable + # + # Defines which key will be used when recovering the password for an account + # config.reset_password_keys = [:email] + + # Time interval you can reset your password with a reset password key. + # Don't put a too small interval or your users won't have the time to + # change their passwords. + config.reset_password_within = 6.hours + + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + # config.sign_in_after_reset_password = true + + # ==> Configuration for :encryptable + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt + # config.encryptor = :sha512 + + # ==> Scopes configuration + # Turn scoped views on. Before rendering "sessions/new", it will first check for + # "users/sessions/new". It's turned off by default because it's slower if you + # are using only default views. + # config.scoped_views = false + + # Configure the default scope given to Warden. By default it's the first + # devise role declared in your routes (usually :user). + # config.default_scope = :user + + # Set this configuration to false if you want /users/sign_out to sign out + # only the current scope. By default, Devise signs out all scopes. + # config.sign_out_all_scopes = true + + # ==> Navigation configuration + # Lists the formats that should be treated as navigational. Formats like + # :html, should redirect to the sign in page when the user does not have + # access, but formats like :xml or :json, should return 401. + # + # If you have any extra navigational formats, like :iphone or :mobile, you + # should add them to the navigational formats lists. + # + # The "*/*" below is required to match Internet Explorer requests. + # config.navigational_formats = ['*/*', :html] + + # The default HTTP method used to sign out a resource. Default is :delete. + config.sign_out_via = :delete + + # ==> OmniAuth + # Add a new OmniAuth provider. Check the wiki for more information on setting + # up on your models and hooks. + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # ==> Warden configuration + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. + # + # config.warden do |manager| + # manager.intercept_401 = false + # manager.default_strategies(scope: :user).unshift :some_external_strategy + # end + + # ==> Mountable engine configurations + # When using Devise inside an engine, let's call it `MyEngine`, and this engine + # is mountable, there are some extra configurations to be taken into account. + # The following options are available, assuming the engine is mounted as: + # + # mount MyEngine, at: '/my_engine' + # + # The router that invoked `devise_for`, in the example above, would be: + # config.router_name = :my_engine + # + # When using OmniAuth, Devise cannot automatically set OmniAuth path, + # so you need to do it manually. For the users scope, it would be: + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Turbolinks configuration + # If your app is using Turbolinks, Turbolinks::Controller needs to be included to make redirection work correctly: + # + # ActiveSupport.on_load(:devise_failure_app) do + # include Turbolinks::Controller + # end + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true +end diff --git a/.framework/rails/backend/config/initializers/filter_parameter_logging.rb b/.framework/rails/backend/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..4a994e1 --- /dev/null +++ b/.framework/rails/backend/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,4 @@ +# Be sure to restart your server when you modify this file. + +# Configure sensitive parameters which will be filtered from the log file. +Rails.application.config.filter_parameters += [:password] diff --git a/.framework/rails/backend/config/initializers/inflections.rb b/.framework/rails/backend/config/initializers/inflections.rb new file mode 100644 index 0000000..ac033bf --- /dev/null +++ b/.framework/rails/backend/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, '\1en' +# inflect.singular /^(ox)en/i, '\1' +# inflect.irregular 'person', 'people' +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym 'RESTful' +# end diff --git a/.framework/rails/backend/config/initializers/json_param_key_transform.rb b/.framework/rails/backend/config/initializers/json_param_key_transform.rb new file mode 100644 index 0000000..7f88929 --- /dev/null +++ b/.framework/rails/backend/config/initializers/json_param_key_transform.rb @@ -0,0 +1,17 @@ +# File: config/initializers/json_param_key_transform.rb +# Transform JSON request param keys from JSON-conventional camelCase to +# Rails-conventional snake_case: +ActionDispatch::Request.parameter_parsers[:json] = lambda { |raw_post| + # Modified from action_dispatch/http/parameters.rb + data = ActiveSupport::JSON.decode(raw_post) + + # Transform camelCase param keys to snake_case + if data.is_a?(Array) + data.map { |item| item.deep_transform_keys!(&:underscore) } + else + data.deep_transform_keys!(&:underscore) + end + + # Return data + data.is_a?(Hash) ? data : { '_json': data } +} diff --git a/.framework/rails/backend/config/initializers/mime_types.rb b/.framework/rails/backend/config/initializers/mime_types.rb new file mode 100644 index 0000000..dc18996 --- /dev/null +++ b/.framework/rails/backend/config/initializers/mime_types.rb @@ -0,0 +1,4 @@ +# Be sure to restart your server when you modify this file. + +# Add new mime types for use in respond_to blocks: +# Mime::Type.register "text/richtext", :rtf diff --git a/.framework/rails/backend/config/initializers/wrap_parameters.rb b/.framework/rails/backend/config/initializers/wrap_parameters.rb new file mode 100644 index 0000000..bbfc396 --- /dev/null +++ b/.framework/rails/backend/config/initializers/wrap_parameters.rb @@ -0,0 +1,14 @@ +# Be sure to restart your server when you modify this file. + +# This file contains settings for ActionController::ParamsWrapper which +# is enabled by default. + +# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. +ActiveSupport.on_load(:action_controller) do + wrap_parameters format: [:json] +end + +# To enable root element in JSON for ActiveRecord objects. +# ActiveSupport.on_load(:active_record) do +# self.include_root_in_json = true +# end diff --git a/.framework/rails/backend/config/locales/devise.en.yml b/.framework/rails/backend/config/locales/devise.en.yml new file mode 100644 index 0000000..260e1c4 --- /dev/null +++ b/.framework/rails/backend/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/.framework/rails/backend/config/locales/en.yml b/.framework/rails/backend/config/locales/en.yml new file mode 100644 index 0000000..decc5a8 --- /dev/null +++ b/.framework/rails/backend/config/locales/en.yml @@ -0,0 +1,33 @@ +# Files in the config/locales directory are used for internationalization +# and are automatically loaded by Rails. If you want to use locales other +# than English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t 'hello' +# +# In views, this is aliased to just `t`: +# +# <%= t('hello') %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# The following keys must be escaped otherwise they will not be retrieved by +# the default I18n backend: +# +# true, false, on, off, yes, no +# +# Instead, surround them with single quotes. +# +# en: +# 'true': 'foo' +# +# To learn more, please read the Rails Internationalization guide +# available at http://guides.rubyonrails.org/i18n.html. + +en: + hello: "Hello world" diff --git a/.framework/rails/backend/config/locales/responders.en.yml b/.framework/rails/backend/config/locales/responders.en.yml new file mode 100644 index 0000000..c3e147a --- /dev/null +++ b/.framework/rails/backend/config/locales/responders.en.yml @@ -0,0 +1,12 @@ +en: + flash: + actions: + create: + notice: '%{resource_name} was successfully created.' + # alert: '%{resource_name} could not be created.' + update: + notice: '%{resource_name} was successfully updated.' + # alert: '%{resource_name} could not be updated.' + destroy: + notice: '%{resource_name} was successfully destroyed.' + alert: '%{resource_name} could not be destroyed.' diff --git a/.framework/rails/backend/config/puma.rb b/.framework/rails/backend/config/puma.rb new file mode 100644 index 0000000..db6198c --- /dev/null +++ b/.framework/rails/backend/config/puma.rb @@ -0,0 +1,34 @@ +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked webserver processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +workers ENV.fetch("WEB_CONCURRENCY") { 3 } + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +preload_app! + +# Allow puma to be restarted by `rails restart` command. +plugin :tmp_restart diff --git a/.framework/rails/backend/config/routes.rb b/.framework/rails/backend/config/routes.rb new file mode 100644 index 0000000..b5ff26a --- /dev/null +++ b/.framework/rails/backend/config/routes.rb @@ -0,0 +1,27 @@ + +Rails.application.routes.draw do + root to: "application#root" + + get '/health', to: 'application#health' + + scope :api, defaults: { format: :json } do + devise_for :users, controllers: { sessions: :sessions, registrations: :registrations }, + path_names: { sign_in: :login } + + resource :user, only: %i[show update] + + resources :profiles, param: :username, only: [:show] do + resource :follow, only: %i[create destroy] + end + + resources :items, param: :slug, except: %i[edit new] do + resource :favorite, only: %i[create destroy] + resources :comments, only: %i[create index destroy] + get :feed, on: :collection + end + + resources :tags, only: [:index] + + resources :ping, only: [:index] + end +end diff --git a/.framework/rails/backend/config/secrets.yml b/.framework/rails/backend/config/secrets.yml new file mode 100644 index 0000000..5482bab --- /dev/null +++ b/.framework/rails/backend/config/secrets.yml @@ -0,0 +1,26 @@ +# Be sure to restart your server when you modify this file. + +# Your secret key is used for verifying the integrity of signed cookies. +# If you change this key, all old signed cookies will become invalid! + +# Make sure the secret is at least 30 characters and all random, +# no regular words or you'll be exposed to dictionary attacks. +# You can use `rails secret` to generate a secure secret key. + +# Make sure the secrets in this file are kept private +# if you're sharing your code publicly. + +# Shared secrets are available across all environments. + +shared: + +# Environmental secrets are only available for that specific environment. + +production: + secret_key_base: ceda0b32a04e63a9b997ec109c980c090bfa85cbdf26fc44288633dc9847b03ad8c460de5bcdb81e7cc0b0f158bb9b11a40105277a44e8d47e18e6639dd8d793 + +development: + secret_key_base: b4ec4bebe065b6d81ff57acc4c81464d6ce8e8488a67391e0d51f2707ebc4ea474d86fa6c52d22adefd141f7236d6f99da046f4945c4773e6ff5f100eb4f29ed + +test: + secret_key_base: 25c0c6b3cfc81e9357d13657f5545d3401da7af97a2be88c37c0d5afff8913f49fa2ba5dd75c21fedc8a974e27a8bd486f318510944899eb535175a5359a7277 diff --git a/.framework/rails/backend/config/secrets.yml.enc b/.framework/rails/backend/config/secrets.yml.enc new file mode 100644 index 0000000..534adbb --- /dev/null +++ b/.framework/rails/backend/config/secrets.yml.enc @@ -0,0 +1 @@ +siBQfbvp2QIrFrw2Nb+S2ZJOKgQrXbV7tHsJWYafnxnruINEIo57G1+KVXIqfTFbvL95wh9R5dKoJj/7iJXk73KUv6VGg2AvmaWU9wEr9/nQsy2ojNAtg6F0T6ImsrzZchRDmycSPQkbQRBeqjDhuHqumSy8sqQOWeeIGM/4A99OxmCQIa2cAoQ09X38Og==--f7rABLaEoAVbrEWg--O15KZL5f10Z882jBsk4udA== \ No newline at end of file diff --git a/.framework/rails/backend/config/spring.rb b/.framework/rails/backend/config/spring.rb new file mode 100644 index 0000000..9fa7863 --- /dev/null +++ b/.framework/rails/backend/config/spring.rb @@ -0,0 +1,6 @@ +%w[ + .ruby-version + .rbenv-vars + tmp/restart.txt + tmp/caching-dev.txt +].each { |path| Spring.watch(path) } diff --git a/.framework/rails/backend/config/storage.yml b/.framework/rails/backend/config/storage.yml new file mode 100644 index 0000000..d32f76e --- /dev/null +++ b/.framework/rails/backend/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket + +# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/.framework/rails/backend/db/migrate/20210412045707_devise_create_users.rb b/.framework/rails/backend/db/migrate/20210412045707_devise_create_users.rb new file mode 100644 index 0000000..78f4547 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412045707_devise_create_users.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class DeviseCreateUsers < ActiveRecord::Migration[4.2] + def change + create_table :users do |t| + ## Database authenticatable + t.string :email, null: false, default: '' + t.string :encrypted_password, null: false, default: '' + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + t.datetime :remember_created_at + + ## Trackable + t.integer :sign_in_count, default: 0, null: false + t.datetime :current_sign_in_at + t.datetime :last_sign_in_at + t.string :current_sign_in_ip + t.string :last_sign_in_ip + + ## Confirmable + # t.string :confirmation_token + # t.datetime :confirmed_at + # t.datetime :confirmation_sent_at + # t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + # t.string :unlock_token # Only if unlock strategy is :email or :both + # t.datetime :locked_at + + t.timestamps null: false + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + # add_index :users, :confirmation_token, unique: true + # add_index :users, :unlock_token, unique: true + end +end diff --git a/.framework/rails/backend/db/migrate/20210412045739_add_profile_fields_to_users.rb b/.framework/rails/backend/db/migrate/20210412045739_add_profile_fields_to_users.rb new file mode 100644 index 0000000..1230e71 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412045739_add_profile_fields_to_users.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddProfileFieldsToUsers < ActiveRecord::Migration[5.0] + def change + add_column :users, :username, :string + add_index :users, :username, unique: true + add_column :users, :image, :string + add_column :users, :bio, :text + end +end diff --git a/.framework/rails/backend/db/migrate/20210412052128_create_items.rb b/.framework/rails/backend/db/migrate/20210412052128_create_items.rb new file mode 100644 index 0000000..7b6cd3d --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412052128_create_items.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateItems < ActiveRecord::Migration[5.0] + def change + create_table :items do |t| + t.string :title + t.string :slug + t.string :description + t.string :image + t.integer :favorites_count + t.references :user, index: true, foreign_key: true + + t.timestamps null: false + end + add_index :items, :slug, unique: true + end +end diff --git a/.framework/rails/backend/db/migrate/20210412054809_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb b/.framework/rails/backend/db/migrate/20210412054809_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..14d8c35 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412054809_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# This migration comes from acts_as_taggable_on_engine (originally 1) +class ActsAsTaggableOnMigration < ActiveRecord::Migration[5.0] + def self.up + create_table :tags do |t| + t.string :name + end + + create_table :taggings do |t| + t.references :tag + + # You should make sure that the column created is + # long enough to store the required class names. + t.references :taggable, polymorphic: true + t.references :tagger, polymorphic: true + + # Limit is created to prevent MySQL error on index + # length for MyISAM table type: http://bit.ly/vgW2Ql + t.string :context, limit: 128 + + t.datetime :created_at + end + + add_index :taggings, :tag_id unless index_exists?(:taggings, :tag_id) + add_index :taggings, %i[taggable_id taggable_type context] + end + + def self.down + drop_table :taggings + drop_table :tags + end +end diff --git a/.framework/rails/backend/db/migrate/20210412054810_add_missing_unique_indices.acts_as_taggable_on_engine.rb b/.framework/rails/backend/db/migrate/20210412054810_add_missing_unique_indices.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..c0aa721 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412054810_add_missing_unique_indices.acts_as_taggable_on_engine.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# This migration comes from acts_as_taggable_on_engine (originally 2) +class AddMissingUniqueIndices < ActiveRecord::Migration[5.0] + def self.up + add_index :tags, :name, unique: true + + remove_index :taggings, :tag_id + remove_index :taggings, %i[taggable_id taggable_type context] + add_index :taggings, + %i[tag_id taggable_id taggable_type context tagger_id tagger_type], + unique: true, name: 'taggings_idx' + end + + def self.down + remove_index :tags, :name + + remove_index :taggings, name: 'taggings_idx' + add_index :taggings, :tag_id + add_index :taggings, %i[taggable_id taggable_type context] + end +end diff --git a/.framework/rails/backend/db/migrate/20210412054811_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb b/.framework/rails/backend/db/migrate/20210412054811_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..871f96f --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412054811_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# This migration comes from acts_as_taggable_on_engine (originally 3) +class AddTaggingsCounterCacheToTags < ActiveRecord::Migration[5.0] + def self.up + add_column :tags, :taggings_count, :integer, default: 0 + + ActsAsTaggableOn::Tag.reset_column_information + ActsAsTaggableOn::Tag.find_each do |tag| + ActsAsTaggableOn::Tag.reset_counters(tag.id, :taggings) + end + end + + def self.down + remove_column :tags, :taggings_count + end +end diff --git a/.framework/rails/backend/db/migrate/20210412054812_add_missing_taggable_index.acts_as_taggable_on_engine.rb b/.framework/rails/backend/db/migrate/20210412054812_add_missing_taggable_index.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..42fe2b7 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412054812_add_missing_taggable_index.acts_as_taggable_on_engine.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# This migration comes from acts_as_taggable_on_engine (originally 4) +class AddMissingTaggableIndex < ActiveRecord::Migration[5.0] + def self.up + add_index :taggings, %i[taggable_id taggable_type context] + end + + def self.down + remove_index :taggings, %i[taggable_id taggable_type context] + end +end diff --git a/.framework/rails/backend/db/migrate/20210412054813_change_collation_for_tag_names.acts_as_taggable_on_engine.rb b/.framework/rails/backend/db/migrate/20210412054813_change_collation_for_tag_names.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..29a11e3 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412054813_change_collation_for_tag_names.acts_as_taggable_on_engine.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# This migration comes from acts_as_taggable_on_engine (originally 5) +# This migration is added to circumvent issue #623 and have special characters +# work properly +class ChangeCollationForTagNames < ActiveRecord::Migration[5.0] + def up + if ActsAsTaggableOn::Utils.using_mysql? + execute('ALTER TABLE tags MODIFY name varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;') + end + end +end diff --git a/.framework/rails/backend/db/migrate/20210412055201_create_favorites.rb b/.framework/rails/backend/db/migrate/20210412055201_create_favorites.rb new file mode 100644 index 0000000..f9ef870 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412055201_create_favorites.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateFavorites < ActiveRecord::Migration[5.0] + def change + create_table :favorites do |t| + t.references :user, index: true, foreign_key: true + t.references :item, index: true, foreign_key: true + + t.timestamps null: false + end + end +end diff --git a/.framework/rails/backend/db/migrate/20210412061113_create_comments.rb b/.framework/rails/backend/db/migrate/20210412061113_create_comments.rb new file mode 100644 index 0000000..12ccb33 --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412061113_create_comments.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateComments < ActiveRecord::Migration[5.0] + def change + create_table :comments do |t| + t.text :body + t.references :user, index: true, foreign_key: true + t.references :item, index: true, foreign_key: true + + t.timestamps null: false + end + end +end diff --git a/.framework/rails/backend/db/migrate/20210412061614_acts_as_follower_migration.rb b/.framework/rails/backend/db/migrate/20210412061614_acts_as_follower_migration.rb new file mode 100644 index 0000000..3fd715a --- /dev/null +++ b/.framework/rails/backend/db/migrate/20210412061614_acts_as_follower_migration.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ActsAsFollowerMigration < ActiveRecord::Migration[5.0] + def self.up + create_table :follows, force: true do |t| + t.references :followable, polymorphic: true, null: false + t.references :follower, polymorphic: true, null: false + t.boolean :blocked, default: false, null: false + t.timestamps + end + + add_index :follows, %w[follower_id follower_type], name: 'fk_follows' + add_index :follows, %w[followable_id followable_type], name: 'fk_followables' + end + + def self.down + drop_table :follows + end +end diff --git a/.framework/rails/backend/db/schema.rb b/.framework/rails/backend/db/schema.rb new file mode 100644 index 0000000..44d2262 --- /dev/null +++ b/.framework/rails/backend/db/schema.rb @@ -0,0 +1,110 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 2021_04_12_061614) do + + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "comments", id: :serial, force: :cascade do |t| + t.text "body" + t.integer "user_id" + t.integer "item_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["item_id"], name: "index_comments_on_item_id" + t.index ["user_id"], name: "index_comments_on_user_id" + end + + create_table "favorites", id: :serial, force: :cascade do |t| + t.integer "user_id" + t.integer "item_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["item_id"], name: "index_favorites_on_item_id" + t.index ["user_id"], name: "index_favorites_on_user_id" + end + + create_table "follows", id: :serial, force: :cascade do |t| + t.string "followable_type", null: false + t.integer "followable_id", null: false + t.string "follower_type", null: false + t.integer "follower_id", null: false + t.boolean "blocked", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["followable_id", "followable_type"], name: "fk_followables" + t.index ["followable_type", "followable_id"], name: "index_follows_on_followable" + t.index ["follower_id", "follower_type"], name: "fk_follows" + t.index ["follower_type", "follower_id"], name: "index_follows_on_follower" + end + + create_table "items", id: :serial, force: :cascade do |t| + t.string "title" + t.string "slug" + t.string "description" + t.string "image" + t.integer "favorites_count" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["slug"], name: "index_items_on_slug", unique: true + t.index ["user_id"], name: "index_items_on_user_id" + end + + create_table "taggings", id: :serial, force: :cascade do |t| + t.integer "tag_id" + t.string "taggable_type" + t.integer "taggable_id" + t.string "tagger_type" + t.integer "tagger_id" + t.string "context", limit: 128 + t.datetime "created_at" + t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true + t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" + t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" + t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger" + end + + create_table "tags", id: :serial, force: :cascade do |t| + t.string "name" + t.integer "taggings_count", default: 0 + t.index ["name"], name: "index_tags_on_name", unique: true + end + + create_table "users", id: :serial, force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.integer "sign_in_count", default: 0, null: false + t.datetime "current_sign_in_at" + t.datetime "last_sign_in_at" + t.string "current_sign_in_ip" + t.string "last_sign_in_ip" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "username" + t.string "image" + t.text "bio" + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["username"], name: "index_users_on_username", unique: true + end + + add_foreign_key "comments", "items" + add_foreign_key "comments", "users" + add_foreign_key "favorites", "items" + add_foreign_key "favorites", "users" + add_foreign_key "items", "users" +end diff --git a/.framework/rails/backend/db/seeds.rb b/.framework/rails/backend/db/seeds.rb new file mode 100644 index 0000000..1beea2a --- /dev/null +++ b/.framework/rails/backend/db/seeds.rb @@ -0,0 +1,7 @@ +# This file should contain all the record creation needed to seed the database with its default values. +# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). +# +# Examples: +# +# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) +# Character.create(name: 'Luke', movie: movies.first) diff --git a/.framework/rails/backend/lib/assets/.keep b/.framework/rails/backend/lib/assets/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/lib/event.rb b/.framework/rails/backend/lib/event.rb new file mode 100644 index 0000000..d548df7 --- /dev/null +++ b/.framework/rails/backend/lib/event.rb @@ -0,0 +1,19 @@ +module Event + def sendEvent(eventName, metadata) + wilcoId = ENV['WILCO_ID'] || File.read(Rails.root.join("../.wilco")) + baseUrl = ENV['ENGINE_BASE_URL'] || "https://engine.wilco.gg" + conn = Faraday.new( + url: baseUrl + "/users/#{wilcoId}/", + headers: {'Content-Type' => 'application/json'} + ) + begin + response = conn.post('event') do |req| + req.headers['Content-Type'] = 'application/json' + req.body = { event: eventName, metadata: metadata}.to_json + end + rescue Exception => e + puts 'failed to send event #{wilcoId} to Wilco engine' + end + response + end +end diff --git a/.framework/rails/backend/lib/tasks/.keep b/.framework/rails/backend/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/log/.keep b/.framework/rails/backend/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/public/404.html b/.framework/rails/backend/public/404.html new file mode 100644 index 0000000..b612547 --- /dev/null +++ b/.framework/rails/backend/public/404.html @@ -0,0 +1,67 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
+
+

The page you were looking for doesn't exist.

+

You may have mistyped the address or the page may have moved.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/.framework/rails/backend/public/422.html b/.framework/rails/backend/public/422.html new file mode 100644 index 0000000..a21f82b --- /dev/null +++ b/.framework/rails/backend/public/422.html @@ -0,0 +1,67 @@ + + + + The change you wanted was rejected (422) + + + + + + +
+
+

The change you wanted was rejected.

+

Maybe you tried to change something you didn't have access to.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/.framework/rails/backend/public/500.html b/.framework/rails/backend/public/500.html new file mode 100644 index 0000000..061abc5 --- /dev/null +++ b/.framework/rails/backend/public/500.html @@ -0,0 +1,66 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
+
+

We're sorry, but something went wrong.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/.framework/rails/backend/public/favicon.ico b/.framework/rails/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/backend/public/robots.txt b/.framework/rails/backend/public/robots.txt new file mode 100644 index 0000000..37b576a --- /dev/null +++ b/.framework/rails/backend/public/robots.txt @@ -0,0 +1 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/.framework/rails/backend/seeds.sh b/.framework/rails/backend/seeds.sh new file mode 100755 index 0000000..8c1a5b6 --- /dev/null +++ b/.framework/rails/backend/seeds.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +bin/rails db:seed diff --git a/.framework/rails/backend/start.sh b/.framework/rails/backend/start.sh new file mode 100755 index 0000000..bc5a109 --- /dev/null +++ b/.framework/rails/backend/start.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +bundle exec rails s diff --git a/.framework/rails/backend/start_rails.sh b/.framework/rails/backend/start_rails.sh new file mode 100755 index 0000000..eec24c7 --- /dev/null +++ b/.framework/rails/backend/start_rails.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +rm -f /tmp/server.pid +bin/rails db:migrate +bin/rails s -b 0.0.0.0 --pid /tmp/server.pid diff --git a/.framework/rails/backend/vendor/.keep b/.framework/rails/backend/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/.framework/rails/charts/.helmignore b/.framework/rails/charts/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/.framework/rails/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/rails/charts/Chart.yaml b/.framework/rails/charts/Chart.yaml new file mode 100644 index 0000000..b2beb19 --- /dev/null +++ b/.framework/rails/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/rails/charts/templates/_helpers.yml b/.framework/rails/charts/templates/_helpers.yml new file mode 100644 index 0000000..49515f2 --- /dev/null +++ b/.framework/rails/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/rails/charts/templates/anythink-backend-deployment.yaml b/.framework/rails/charts/templates/anythink-backend-deployment.yaml new file mode 100644 index 0000000..a71f3a7 --- /dev/null +++ b/.framework/rails/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 + - "rm -f /tmp/server.pid && exec rails s -b 0.0.0.0 --pid /tmp/server.pid" + env: + - name: DATABASE_URL + value: "{{ .Values.database.connectionProtocol}}{{ .Values.database.env.userName}}:@{{ .Values.database.serviceName }}:{{ .Values.database.servicePort }}" + - name: RAILS_ENV + value: development + - name: PORT + value: "{{ .Values.backend.containerPort }}" + 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: + - command: + - sh + - -c + - "rails db:migrate && ./seeds.sh" + env: + - name: DATABASE_URL + value: "{{ .Values.database.connectionProtocol}}{{ .Values.database.env.userName}}:@{{ .Values.database.serviceName }}:{{ .Values.database.servicePort }}" + - name: RAILS_ENV + value: development + - name: PORT + value: "{{ .Values.backend.containerPort }}" + image: "{{ include "anythink-tenant.backendRepository" .}}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + name: db-migrations + restartPolicy: Always diff --git a/.framework/rails/charts/templates/anythink-backend-service.yaml b/.framework/rails/charts/templates/anythink-backend-service.yaml new file mode 100644 index 0000000..21bb516 --- /dev/null +++ b/.framework/rails/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/rails/charts/templates/anythink-frontend-deployment.yaml b/.framework/rails/charts/templates/anythink-frontend-deployment.yaml new file mode 100644 index 0000000..f9be249 --- /dev/null +++ b/.framework/rails/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/rails/charts/templates/anythink-frontend-service.yaml b/.framework/rails/charts/templates/anythink-frontend-service.yaml new file mode 100644 index 0000000..217f8c5 --- /dev/null +++ b/.framework/rails/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/rails/charts/templates/database-deployment.yaml b/.framework/rails/charts/templates/database-deployment.yaml new file mode 100644 index 0000000..667a23a --- /dev/null +++ b/.framework/rails/charts/templates/database-deployment.yaml @@ -0,0 +1,42 @@ +{{- 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 }} + 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/rails/charts/templates/database-pvc.yaml b/.framework/rails/charts/templates/database-pvc.yaml new file mode 100644 index 0000000..88517f3 --- /dev/null +++ b/.framework/rails/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/rails/charts/templates/database-service.yaml b/.framework/rails/charts/templates/database-service.yaml new file mode 100644 index 0000000..80b47d3 --- /dev/null +++ b/.framework/rails/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/rails/charts/values.yaml b/.framework/rails/charts/values.yaml new file mode 100644 index 0000000..026452c --- /dev/null +++ b/.framework/rails/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: postgres:// + serviceName: postgres-rails + containerPort: 5433 + servicePort: 5432 + replicaCount: 1 + env: + userName: user + password: password + 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/rails/docker-compose.yml b/.framework/rails/docker-compose.yml new file mode 100644 index 0000000..5447d2b --- /dev/null +++ b/.framework/rails/docker-compose.yml @@ -0,0 +1,61 @@ +version: "3.8" +services: + anythink-backend-rails: + build: ./backend + container_name: anythink-backend-rails + command: sh -c "cd backend && bundle install && /wait-for-it.sh postgres-rails:5432 -q -t 60 && ./start_rails.sh" + + working_dir: /usr/src + volumes: + - ./:/usr/src/ + ports: + - "3000:3000" + environment: + - RAILS_ENV=development + - PORT=3000 + - DATABASE_URL=postgres://user:@postgres-rails:5432 + - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN} + depends_on: + - "postgres-rails" + + anythink-frontend-react: + build: ./frontend + container_name: anythink-frontend-react + command: sh -c "cd frontend && yarn install && /wait-for-it.sh anythink-backend-rails:3000 -t 120 --strict -- curl --head -X GET --retry 30 --retry-connrefused --retry-delay 1 anythink-backend-rails: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-rails" + + postgres-rails: + container_name: postgres-rails + restart: on-failure + image: postgres + logging: + driver: none + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_USER: user + POSTGRES_PASSWORD: password + volumes: + - ~/posgres/data:/data/db + ports: + - '5433:5432' + + anythink-ack: + image: public.ecr.aws/v0a2l7y2/wilco/anythink-ack:latest + container_name: anythink-ack + environment: + - GITHUB_TOKEN=$GITHUB_TOKEN + - CODESPACE_NAME=$CODESPACE_NAME + depends_on: + - "anythink-frontend-react" diff --git a/.framework/react/frontend/.eslintignore b/.framework/react/frontend/.eslintignore new file mode 100644 index 0000000..dd87e2d --- /dev/null +++ b/.framework/react/frontend/.eslintignore @@ -0,0 +1,2 @@ +node_modules +build diff --git a/.framework/react/frontend/.gitignore b/.framework/react/frontend/.gitignore new file mode 100644 index 0000000..edfc119 --- /dev/null +++ b/.framework/react/frontend/.gitignore @@ -0,0 +1,16 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +build + +# misc +.DS_Store +.env +npm-debug.log +.idea \ No newline at end of file diff --git a/.framework/react/frontend/Dockerfile b/.framework/react/frontend/Dockerfile new file mode 100644 index 0000000..c8ba554 --- /dev/null +++ b/.framework/react/frontend/Dockerfile @@ -0,0 +1 @@ +FROM public.ecr.aws/v0a2l7y2/wilco/anythink-frontend-react:latest diff --git a/.framework/react/frontend/Dockerfile.aws b/.framework/react/frontend/Dockerfile.aws new file mode 100644 index 0000000..9485ac1 --- /dev/null +++ b/.framework/react/frontend/Dockerfile.aws @@ -0,0 +1,9 @@ +FROM node:16 +WORKDIR /usr/src + +COPY frontend ./frontend +COPY .wilco ./.wilco + +# Pre-install npm packages +WORKDIR /usr/src/frontend +RUN yarn install diff --git a/.framework/react/frontend/jest.config.js b/.framework/react/frontend/jest.config.js new file mode 100644 index 0000000..582fe82 --- /dev/null +++ b/.framework/react/frontend/jest.config.js @@ -0,0 +1,8 @@ +const config = { + verbose: true, + jest: { + setupFilesAfterEnv: ["src/setupTests.js"], + }, +}; + +module.exports = config; diff --git a/.framework/react/frontend/package.json b/.framework/react/frontend/package.json new file mode 100644 index 0000000..45a643d --- /dev/null +++ b/.framework/react/frontend/package.json @@ -0,0 +1,74 @@ +{ + "name": "anythink-market-front", + "version": "0.1.0", + "engines": { + "node": "^16" + }, + "private": true, + "devDependencies": { + "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", + "core-js": "^3.25.1", + "enzyme": "^3.11.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-react": "^7.26.1", + "prettier": "2.4.1", + "react-test-renderer": "^17.0.2", + "redux-mock-store": "^1.5.4" + }, + "dependencies": { + "@babel/core": "^7.18.13", + "@babel/plugin-proposal-private-property-in-object": "^7.18.6", + "@babel/plugin-syntax-flow": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.10", + "bootstrap": "^4.6.0", + "bootstrap-icons": "^1.7.1", + "history": "^4.6.3", + "jquery": "^3.6.1", + "marked": "^0.3.6", + "postcss": "^8.4.16", + "prop-types": "^15.5.10", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-redux": "^5.0.7", + "react-router-dom": "^6.9.0", + "react-scripts": "^5.0.1", + "redux": "^3.6.0", + "redux-devtools-extension": "^2.13.2", + "sass": "^1.45.0", + "superagent": "^3.8.2", + "superagent-promise": "^1.1.0", + "typescript": "^4.8.2" + }, + "scripts": { + "start": "REACT_APP_WILCO_ID=${WILCO_ID:-\"$(cat ../.wilco)\"} react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject", + "format": "yarn prettier --write .", + "lint": "yarn eslint . && yarn prettier --check ." + }, + "eslintConfig": { + "extends": [ + "react-app", + "eslint:recommended" + ], + "rules": { + "no-var": "error" + } + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "resolutions": { + "autoprefixer": "10.4.5" + } +} diff --git a/.framework/react/frontend/public/50precentoff.png b/.framework/react/frontend/public/50precentoff.png new file mode 100644 index 0000000000000000000000000000000000000000..4c835a18da3ab0c1cce11600d24a40339221c040 GIT binary patch literal 58158 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