From 656a191e2c01b0db143a98b41b01b863282e1d54 Mon Sep 17 00:00:00 2001 From: Misha Merkushin Date: Thu, 29 Feb 2024 22:29:43 +0300 Subject: [PATCH 1/3] feat: initial commit --- .github/workflows/quest.yml | 50 ++ .github/workflows/voyage.yml | 50 ++ README.md | 37 +- infra/dip.yml | 22 + infra/docker-compose.yml | 40 ++ quest/.dockerdev/.psqlrc | 23 + quest/.dockerdev/Dockerfile | 55 ++ quest/.dockerdev/docker-compose.yml | 112 ++++ quest/.dockerignore | 24 + quest/.env | 6 + quest/.env.development | 3 + quest/.env.test | 3 + quest/.gitignore | 68 +++ quest/.irbrc | 7 + quest/.rspec | 3 + quest/.rubocop.yml | 91 +++ quest/.schked | 1 + quest/CODEOWNERS | 0 quest/Gemfile | 59 ++ quest/Gemfile.lock | 518 +++++++++++++++++ quest/Outboxfile | 5 + quest/README.md | 28 + quest/Rakefile | 6 + quest/Schkedfile | 9 + quest/api/events/order.proto | 23 + quest/api/google/type/money.proto | 43 ++ .../api/v1/orders/completions_controller.rb | 29 + .../api/v1/orders/outbox_items_controller.rb | 15 + .../controllers/api/v1/orders_controller.rb | 23 + .../app/controllers/application_controller.rb | 28 + quest/app/controllers/welcome_controller.rb | 11 + quest/app/decoders/application_decoder.rb | 24 + quest/app/decoders/order_decoder.rb | 22 + quest/app/encoders/application_encoder.rb | 35 ++ quest/app/encoders/order_encoder.rb | 23 + .../app/interactors/application_interactor.rb | 10 + .../app/interactors/orders/complete_order.rb | 35 ++ quest/app/interactors/orders/create_order.rb | 24 + .../orders/invalid_complete_order.rb | 48 ++ quest/app/lib/error_tracker.rb | 26 + quest/app/models/application_record.rb | 5 + quest/app/models/order.rb | 17 + quest/app/models/order_outbox_item.rb | 5 + .../app/serializers/application_serializer.rb | 5 + .../order_outbox_item_serializer.rb | 19 + quest/app/serializers/order_serializer.rb | 13 + .../validation_errors_serializer.rb | 13 + quest/bin/grpc_gen | 20 + quest/bin/rails | 4 + quest/bin/rake | 4 + quest/bin/setup | 32 ++ quest/build/Dockerfile | 63 ++ quest/config.ru | 4 + quest/config/application.rb | 34 ++ quest/config/boot.rb | 3 + quest/config/configs/application_config.rb | 17 + quest/config/configs/redis_config.rb | 26 + quest/config/database.yml | 24 + quest/config/environment.rb | 5 + quest/config/environments/development.rb | 77 +++ quest/config/environments/production.rb | 98 ++++ quest/config/environments/staging.rb | 7 + quest/config/environments/test.rb | 57 ++ quest/config/initializers/0_redis.rb | 5 + .../initializers/backtrace_silencers.rb | 1 + .../initializers/filter_parameter_logging.rb | 3 + .../config/initializers/http_health_check.rb | 14 + quest/config/initializers/outbox.rb | 5 + quest/config/initializers/schked.rb | 5 + quest/config/initializers/sentry.rb | 10 + quest/config/initializers/sidekiq.rb | 13 + quest/config/initializers/wrap_parameters.rb | 3 + quest/config/initializers/yabeda.rb | 6 + quest/config/kafka_producer.yml | 33 ++ quest/config/outbox.yml | 31 + quest/config/puma.rb | 50 ++ quest/config/redis.yml | 20 + quest/config/routes.rb | 20 + quest/config/schedule.rb | 9 + quest/config/sidekiq.yml | 4 + .../migrate/20230502135214_create_orders.rb | 16 + ...0230503142323_create_order_outbox_items.rb | 23 + .../20230512120120_add_uuid_to_orders.rb | 9 + quest/db/schema.rb | 51 ++ quest/db/seeds.rb | 12 + quest/dip.yml | 76 +++ quest/lefthook-local.dip_example.yml | 4 + quest/lefthook.yml | 6 + quest/lib/seeds/dsl.rb | 71 +++ quest/log/.keep | 0 quest/pkg/client/.keep | 0 quest/pkg/server/.keep | 0 quest/pkg/server/events/order_pb.rb | 34 ++ quest/public/robots.txt | 1 + .../v1/orders/completions_controller_spec.rb | 39 ++ .../v1/orders/outbox_items_controller_spec.rb | 20 + .../api/v1/orders_controller_spec.rb | 31 + .../controllers/welcome_controller_spec.rb | 10 + quest/spec/encoders/order_encoder_spec.rb | 26 + quest/spec/factories/order_factory.rb | 19 + .../factories/order_outbox_item_factory.rb | 16 + .../interactors/orders/complete_order_spec.rb | 23 + .../interactors/orders/create_order_spec.rb | 22 + .../orders/invalid_complete_order_spec.rb | 57 ++ quest/spec/models/order_outbox_item_spec.rb | 7 + quest/spec/models/order_spec.rb | 9 + quest/spec/rails_helper.rb | 47 ++ .../order_outbox_item_serializer_spec.rb | 28 + .../spec/serializers/order_serializer_spec.rb | 23 + .../validation_errors_serializer_spec.rb | 27 + quest/spec/spec_helper.rb | 35 ++ quest/tmp/.keep | 0 voyage/.dockerdev/.psqlrc | 23 + voyage/.dockerdev/Dockerfile | 55 ++ voyage/.dockerdev/docker-compose.yml | 118 ++++ voyage/.dockerignore | 24 + voyage/.env | 6 + voyage/.env.development | 3 + voyage/.env.test | 3 + voyage/.gitignore | 68 +++ voyage/.irbrc | 7 + voyage/.rspec | 3 + voyage/.rubocop.yml | 91 +++ voyage/.schked | 1 + voyage/CODEOWNERS | 0 voyage/Gemfile | 60 ++ voyage/Gemfile.lock | 541 ++++++++++++++++++ voyage/Kafkafile | 3 + voyage/Outboxfile | 5 + voyage/README.md | 28 + voyage/Rakefile | 6 + voyage/Schkedfile | 9 + voyage/api/google/type/money.proto | 43 ++ .../api/v1/orders/inbox_items_controller.rb | 16 + .../api/v1/orders/processings_controller.rb | 14 + .../controllers/api/v1/orders_controller.rb | 16 + .../app/controllers/application_controller.rb | 37 ++ voyage/app/controllers/welcome_controller.rb | 11 + voyage/app/decoders/application_decoder.rb | 30 + voyage/app/decoders/order_decoder.rb | 22 + .../app/interactors/application_interactor.rb | 10 + .../inbox_importers/base_importer.rb | 12 + .../inbox_importers/order_importer.rb | 34 ++ .../outbox_transport_factory.rb | 9 + .../app/interactors/orders/process_order.rb | 15 + voyage/app/lib/error_tracker.rb | 26 + voyage/app/models/application_record.rb | 5 + voyage/app/models/order.rb | 16 + voyage/app/models/order_inbox_item.rb | 5 + .../app/serializers/application_serializer.rb | 5 + .../order_inbox_item_serializer.rb | 19 + voyage/app/serializers/order_serializer.rb | 13 + .../validation_errors_serializer.rb | 13 + voyage/bin/grpc_gen | 20 + voyage/bin/rails | 4 + voyage/bin/rake | 4 + voyage/bin/setup | 32 ++ voyage/build/Dockerfile | 63 ++ voyage/config.ru | 4 + voyage/config/application.rb | 34 ++ voyage/config/boot.rb | 3 + voyage/config/configs/application_config.rb | 17 + voyage/config/configs/redis_config.rb | 26 + voyage/config/database.yml | 24 + voyage/config/environment.rb | 5 + voyage/config/environments/development.rb | 77 +++ voyage/config/environments/production.rb | 98 ++++ voyage/config/environments/staging.rb | 7 + voyage/config/environments/test.rb | 57 ++ voyage/config/initializers/0_redis.rb | 5 + .../initializers/backtrace_silencers.rb | 1 + .../initializers/filter_parameter_logging.rb | 3 + .../config/initializers/http_health_check.rb | 14 + voyage/config/initializers/outbox.rb | 5 + voyage/config/initializers/pagy.rb | 240 ++++++++ voyage/config/initializers/schked.rb | 5 + voyage/config/initializers/sentry.rb | 10 + voyage/config/initializers/sidekiq.rb | 13 + voyage/config/initializers/wrap_parameters.rb | 3 + voyage/config/initializers/yabeda.rb | 6 + voyage/config/kafka_consumer.yml | 40 ++ voyage/config/logging.yml | 22 + voyage/config/outbox.yml | 31 + voyage/config/puma.rb | 50 ++ voyage/config/redis.yml | 20 + voyage/config/routes.rb | 16 + voyage/config/schedule.rb | 9 + voyage/config/sidekiq.yml | 4 + .../migrate/20230511132409_create_orders.rb | 19 + ...20230512065059_create_order_inbox_items.rb | 23 + voyage/db/schema.rb | 51 ++ .../deps/services/seeker/events/order.proto | 23 + voyage/dip.yml | 76 +++ voyage/lefthook-local.dip_example.yml | 4 + voyage/lefthook.yml | 6 + voyage/lib/seeds/dsl.rb | 71 +++ voyage/log/.keep | 0 voyage/pkg/client/.keep | 0 voyage/pkg/client/quest/events/order_pb.rb | 34 ++ voyage/pkg/server/events/.keep | 0 voyage/public/robots.txt | 1 + .../v1/orders/inbox_items_controller_spec.rb | 20 + .../v1/orders/processings_controller_spec.rb | 28 + .../api/v1/orders_controller_spec.rb | 30 + .../controllers/welcome_controller_spec.rb | 10 + voyage/spec/decoders/order_decoder_spec.rb | 11 + voyage/spec/factories/order_factory.rb | 15 + .../factories/order_inbox_item_factory.rb | 15 + .../inbox_importers/order_importer_spec.rb | 22 + .../interactors/orders/process_order_spec.rb | 23 + voyage/spec/models/order_inbox_item_spec.rb | 7 + voyage/spec/models/order_spec.rb | 9 + voyage/spec/rails_helper.rb | 47 ++ .../order_inbox_item_serializer_spec.rb | 28 + .../spec/serializers/order_serializer_spec.rb | 23 + .../validation_errors_serializer_spec.rb | 27 + voyage/spec/spec_helper.rb | 35 ++ voyage/tmp/.keep | 0 218 files changed, 6219 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/quest.yml create mode 100644 .github/workflows/voyage.yml create mode 100644 infra/dip.yml create mode 100644 infra/docker-compose.yml create mode 100644 quest/.dockerdev/.psqlrc create mode 100644 quest/.dockerdev/Dockerfile create mode 100644 quest/.dockerdev/docker-compose.yml create mode 100644 quest/.dockerignore create mode 100644 quest/.env create mode 100644 quest/.env.development create mode 100644 quest/.env.test create mode 100644 quest/.gitignore create mode 100644 quest/.irbrc create mode 100644 quest/.rspec create mode 100644 quest/.rubocop.yml create mode 100644 quest/.schked create mode 100644 quest/CODEOWNERS create mode 100644 quest/Gemfile create mode 100644 quest/Gemfile.lock create mode 100644 quest/Outboxfile create mode 100644 quest/README.md create mode 100644 quest/Rakefile create mode 100644 quest/Schkedfile create mode 100644 quest/api/events/order.proto create mode 100644 quest/api/google/type/money.proto create mode 100644 quest/app/controllers/api/v1/orders/completions_controller.rb create mode 100644 quest/app/controllers/api/v1/orders/outbox_items_controller.rb create mode 100644 quest/app/controllers/api/v1/orders_controller.rb create mode 100644 quest/app/controllers/application_controller.rb create mode 100644 quest/app/controllers/welcome_controller.rb create mode 100644 quest/app/decoders/application_decoder.rb create mode 100644 quest/app/decoders/order_decoder.rb create mode 100644 quest/app/encoders/application_encoder.rb create mode 100644 quest/app/encoders/order_encoder.rb create mode 100644 quest/app/interactors/application_interactor.rb create mode 100644 quest/app/interactors/orders/complete_order.rb create mode 100644 quest/app/interactors/orders/create_order.rb create mode 100644 quest/app/interactors/orders/invalid_complete_order.rb create mode 100644 quest/app/lib/error_tracker.rb create mode 100644 quest/app/models/application_record.rb create mode 100644 quest/app/models/order.rb create mode 100644 quest/app/models/order_outbox_item.rb create mode 100644 quest/app/serializers/application_serializer.rb create mode 100644 quest/app/serializers/order_outbox_item_serializer.rb create mode 100644 quest/app/serializers/order_serializer.rb create mode 100644 quest/app/serializers/validation_errors_serializer.rb create mode 100755 quest/bin/grpc_gen create mode 100755 quest/bin/rails create mode 100755 quest/bin/rake create mode 100755 quest/bin/setup create mode 100644 quest/build/Dockerfile create mode 100644 quest/config.ru create mode 100644 quest/config/application.rb create mode 100644 quest/config/boot.rb create mode 100644 quest/config/configs/application_config.rb create mode 100644 quest/config/configs/redis_config.rb create mode 100644 quest/config/database.yml create mode 100644 quest/config/environment.rb create mode 100644 quest/config/environments/development.rb create mode 100644 quest/config/environments/production.rb create mode 100644 quest/config/environments/staging.rb create mode 100644 quest/config/environments/test.rb create mode 100644 quest/config/initializers/0_redis.rb create mode 100644 quest/config/initializers/backtrace_silencers.rb create mode 100644 quest/config/initializers/filter_parameter_logging.rb create mode 100644 quest/config/initializers/http_health_check.rb create mode 100644 quest/config/initializers/outbox.rb create mode 100644 quest/config/initializers/schked.rb create mode 100644 quest/config/initializers/sentry.rb create mode 100644 quest/config/initializers/sidekiq.rb create mode 100644 quest/config/initializers/wrap_parameters.rb create mode 100644 quest/config/initializers/yabeda.rb create mode 100644 quest/config/kafka_producer.yml create mode 100644 quest/config/outbox.yml create mode 100644 quest/config/puma.rb create mode 100644 quest/config/redis.yml create mode 100644 quest/config/routes.rb create mode 100644 quest/config/schedule.rb create mode 100644 quest/config/sidekiq.yml create mode 100644 quest/db/migrate/20230502135214_create_orders.rb create mode 100644 quest/db/migrate/20230503142323_create_order_outbox_items.rb create mode 100644 quest/db/migrate/20230512120120_add_uuid_to_orders.rb create mode 100644 quest/db/schema.rb create mode 100644 quest/db/seeds.rb create mode 100644 quest/dip.yml create mode 100644 quest/lefthook-local.dip_example.yml create mode 100644 quest/lefthook.yml create mode 100644 quest/lib/seeds/dsl.rb create mode 100644 quest/log/.keep create mode 100644 quest/pkg/client/.keep create mode 100644 quest/pkg/server/.keep create mode 100644 quest/pkg/server/events/order_pb.rb create mode 100644 quest/public/robots.txt create mode 100644 quest/spec/controllers/api/v1/orders/completions_controller_spec.rb create mode 100644 quest/spec/controllers/api/v1/orders/outbox_items_controller_spec.rb create mode 100644 quest/spec/controllers/api/v1/orders_controller_spec.rb create mode 100644 quest/spec/controllers/welcome_controller_spec.rb create mode 100644 quest/spec/encoders/order_encoder_spec.rb create mode 100644 quest/spec/factories/order_factory.rb create mode 100644 quest/spec/factories/order_outbox_item_factory.rb create mode 100644 quest/spec/interactors/orders/complete_order_spec.rb create mode 100644 quest/spec/interactors/orders/create_order_spec.rb create mode 100644 quest/spec/interactors/orders/invalid_complete_order_spec.rb create mode 100644 quest/spec/models/order_outbox_item_spec.rb create mode 100644 quest/spec/models/order_spec.rb create mode 100644 quest/spec/rails_helper.rb create mode 100644 quest/spec/serializers/order_outbox_item_serializer_spec.rb create mode 100644 quest/spec/serializers/order_serializer_spec.rb create mode 100644 quest/spec/serializers/validation_errors_serializer_spec.rb create mode 100644 quest/spec/spec_helper.rb create mode 100644 quest/tmp/.keep create mode 100644 voyage/.dockerdev/.psqlrc create mode 100644 voyage/.dockerdev/Dockerfile create mode 100644 voyage/.dockerdev/docker-compose.yml create mode 100644 voyage/.dockerignore create mode 100644 voyage/.env create mode 100644 voyage/.env.development create mode 100644 voyage/.env.test create mode 100644 voyage/.gitignore create mode 100644 voyage/.irbrc create mode 100644 voyage/.rspec create mode 100644 voyage/.rubocop.yml create mode 100644 voyage/.schked create mode 100644 voyage/CODEOWNERS create mode 100644 voyage/Gemfile create mode 100644 voyage/Gemfile.lock create mode 100644 voyage/Kafkafile create mode 100644 voyage/Outboxfile create mode 100644 voyage/README.md create mode 100644 voyage/Rakefile create mode 100644 voyage/Schkedfile create mode 100644 voyage/api/google/type/money.proto create mode 100644 voyage/app/controllers/api/v1/orders/inbox_items_controller.rb create mode 100644 voyage/app/controllers/api/v1/orders/processings_controller.rb create mode 100644 voyage/app/controllers/api/v1/orders_controller.rb create mode 100644 voyage/app/controllers/application_controller.rb create mode 100644 voyage/app/controllers/welcome_controller.rb create mode 100644 voyage/app/decoders/application_decoder.rb create mode 100644 voyage/app/decoders/order_decoder.rb create mode 100644 voyage/app/interactors/application_interactor.rb create mode 100644 voyage/app/interactors/inbox_importers/base_importer.rb create mode 100644 voyage/app/interactors/inbox_importers/order_importer.rb create mode 100644 voyage/app/interactors/inbox_importers/outbox_transport_factory.rb create mode 100644 voyage/app/interactors/orders/process_order.rb create mode 100644 voyage/app/lib/error_tracker.rb create mode 100644 voyage/app/models/application_record.rb create mode 100644 voyage/app/models/order.rb create mode 100644 voyage/app/models/order_inbox_item.rb create mode 100644 voyage/app/serializers/application_serializer.rb create mode 100644 voyage/app/serializers/order_inbox_item_serializer.rb create mode 100644 voyage/app/serializers/order_serializer.rb create mode 100644 voyage/app/serializers/validation_errors_serializer.rb create mode 100755 voyage/bin/grpc_gen create mode 100755 voyage/bin/rails create mode 100755 voyage/bin/rake create mode 100755 voyage/bin/setup create mode 100644 voyage/build/Dockerfile create mode 100644 voyage/config.ru create mode 100644 voyage/config/application.rb create mode 100644 voyage/config/boot.rb create mode 100644 voyage/config/configs/application_config.rb create mode 100644 voyage/config/configs/redis_config.rb create mode 100644 voyage/config/database.yml create mode 100644 voyage/config/environment.rb create mode 100644 voyage/config/environments/development.rb create mode 100644 voyage/config/environments/production.rb create mode 100644 voyage/config/environments/staging.rb create mode 100644 voyage/config/environments/test.rb create mode 100644 voyage/config/initializers/0_redis.rb create mode 100644 voyage/config/initializers/backtrace_silencers.rb create mode 100644 voyage/config/initializers/filter_parameter_logging.rb create mode 100644 voyage/config/initializers/http_health_check.rb create mode 100644 voyage/config/initializers/outbox.rb create mode 100644 voyage/config/initializers/pagy.rb create mode 100644 voyage/config/initializers/schked.rb create mode 100644 voyage/config/initializers/sentry.rb create mode 100644 voyage/config/initializers/sidekiq.rb create mode 100644 voyage/config/initializers/wrap_parameters.rb create mode 100644 voyage/config/initializers/yabeda.rb create mode 100644 voyage/config/kafka_consumer.yml create mode 100644 voyage/config/logging.yml create mode 100644 voyage/config/outbox.yml create mode 100644 voyage/config/puma.rb create mode 100644 voyage/config/redis.yml create mode 100644 voyage/config/routes.rb create mode 100644 voyage/config/schedule.rb create mode 100644 voyage/config/sidekiq.yml create mode 100644 voyage/db/migrate/20230511132409_create_orders.rb create mode 100644 voyage/db/migrate/20230512065059_create_order_inbox_items.rb create mode 100644 voyage/db/schema.rb create mode 100644 voyage/deps/services/seeker/events/order.proto create mode 100644 voyage/dip.yml create mode 100644 voyage/lefthook-local.dip_example.yml create mode 100644 voyage/lefthook.yml create mode 100644 voyage/lib/seeds/dsl.rb create mode 100644 voyage/log/.keep create mode 100644 voyage/pkg/client/.keep create mode 100644 voyage/pkg/client/quest/events/order_pb.rb create mode 100644 voyage/pkg/server/events/.keep create mode 100644 voyage/public/robots.txt create mode 100644 voyage/spec/controllers/api/v1/orders/inbox_items_controller_spec.rb create mode 100644 voyage/spec/controllers/api/v1/orders/processings_controller_spec.rb create mode 100644 voyage/spec/controllers/api/v1/orders_controller_spec.rb create mode 100644 voyage/spec/controllers/welcome_controller_spec.rb create mode 100644 voyage/spec/decoders/order_decoder_spec.rb create mode 100644 voyage/spec/factories/order_factory.rb create mode 100644 voyage/spec/factories/order_inbox_item_factory.rb create mode 100644 voyage/spec/interactors/inbox_importers/order_importer_spec.rb create mode 100644 voyage/spec/interactors/orders/process_order_spec.rb create mode 100644 voyage/spec/models/order_inbox_item_spec.rb create mode 100644 voyage/spec/models/order_spec.rb create mode 100644 voyage/spec/rails_helper.rb create mode 100644 voyage/spec/serializers/order_inbox_item_serializer_spec.rb create mode 100644 voyage/spec/serializers/order_serializer_spec.rb create mode 100644 voyage/spec/serializers/validation_errors_serializer_spec.rb create mode 100644 voyage/spec/spec_helper.rb create mode 100644 voyage/tmp/.keep diff --git a/.github/workflows/quest.yml b/.github/workflows/quest.yml new file mode 100644 index 0000000..4cf090e --- /dev/null +++ b/.github/workflows/quest.yml @@ -0,0 +1,50 @@ +name: Quest + +on: + push: + branches: [ master ] + pull_request: + branches: [ '**' ] + +jobs: + lint: + name: Rubocop + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./quest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Setup Ruby w/ same version as image + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + - name: Install dependencies + run: | + docker network create infranet + gem install dip + dip bundle install + - name: Run linter + run: dip rubocop + + test: + name: Rspec + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./quest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Setup Ruby w/ same version as image + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + - name: Install dependencies + run: | + docker network create infranet + gem install dip + dip provision + - name: Run tests + run: dip rspec --format RspecJunitFormatter --out test-results/rspec.xml --format documentation diff --git a/.github/workflows/voyage.yml b/.github/workflows/voyage.yml new file mode 100644 index 0000000..f4e1233 --- /dev/null +++ b/.github/workflows/voyage.yml @@ -0,0 +1,50 @@ +name: Voyage + +on: + push: + branches: [ master ] + pull_request: + branches: [ '**' ] + +jobs: + lint: + name: Rubocop + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./voyage + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Setup Ruby w/ same version as image + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + - name: Install dependencies + run: | + docker network create infranet + gem install dip + dip bundle install + - name: Run linter + run: dip rubocop + + test: + name: Rspec + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./voyage + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Setup Ruby w/ same version as image + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + - name: Install dependencies + run: | + docker network create infranet + gem install dip + dip provision + - name: Run tests + run: dip rspec --format RspecJunitFormatter --out test-results/rspec.xml --format documentation diff --git a/README.md b/README.md index 276efc2..d5aac8c 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# outbox-example-apps +# Outbox example apps + +This is example applications which uses: +- [sbmt-outbox](https://github.com/SberMarket-Tech/sbmt-outbox) +- [sbmt-kafka_producer](https://github.com/SberMarket-Tech/sbmt-kafka_producer) +- [sbmt-kafka_consumer](https://github.com/SberMarket-Tech/sbmt-kafka_consumer) + +## How to run + +1. Install [Dip](https://github.com/bibendi/dip) +2. Start [Kafka](./infra/) broker `cd infra && dip up` +3. Start [Quest](./quest/) application `cd quest && dip up` +4. Start [Voyage](./voyage/) application `cd voyage && dip up` + +## Test case + +1. Create an order in Quest app + +```shell +curl --location http://localhost:3000/api/v1/orders \ +--form '[order]name=Foo' \ +--form '[order]qty=3' \ +--form '[order]price=42' +``` + +2. Send the order to Vayage app through Outbox pattern + +```shell +curl --request POST --location http://localhost:3000/api/v1/orders//completion +``` + +3. Show the imported order + +```shell +curl --location http://localhost:3001/api/v1/orders +``` diff --git a/infra/dip.yml b/infra/dip.yml new file mode 100644 index 0000000..8e2905b --- /dev/null +++ b/infra/dip.yml @@ -0,0 +1,22 @@ +version: '7.5' + +compose: + files: + - docker-compose.yml + +interaction: + bash: + description: Open a Bash shell + service: kafka + command: bash + + kafka: + description: Kafka scripts, see more at https://kafka.apache.org/documentation + service: kafka + subcommands: + topics: + command: kafka-topics.sh --bootstrap-server kafka:9092 + consumer: + command: kafka-console-consumer.sh --bootstrap-server kafka:9092 + consumer-groups: + command: kafka-consumer-groups.sh --bootstrap-server kafka:9092 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..693f912 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,40 @@ +services: + zookeeper: + image: bitnami/zookeeper:3.9 + environment: + ALLOW_ANONYMOUS_LOGIN: "yes" + volumes: + - zookeeper:/bitnami + ports: + - 2181 + networks: + - default + - infranet + + kafka: + image: bitnami/kafka:3.6 + depends_on: + - zookeeper + environment: + ALLOW_PLAINTEXT_LISTENER: "yes" + KAFKA_ENABLE_KRAFT: "no" + KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094 + KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT + volumes: + - kafka:/data + ports: + - 9092 + - 9094 + networks: + - default + - infranet + +volumes: + zookeeper: + kafka: + +networks: + infranet: + name: infranet diff --git a/quest/.dockerdev/.psqlrc b/quest/.dockerdev/.psqlrc new file mode 100644 index 0000000..7b8dc9e --- /dev/null +++ b/quest/.dockerdev/.psqlrc @@ -0,0 +1,23 @@ +-- Don't display the "helpful" message on startup. +\set QUIET 1 + +-- psql writes to a temporary file before then moving that temporary file on top of the old history file +-- a bind mount of a file only bind mounts the inode, so a rename like this won't ever work +\set HISTFILE /var/log/psql_history/.psql_history + +-- Use best available output format +\x auto + +-- Verbose error reports +\set VERBOSITY verbose + +-- If a command is run more than once in a row, +-- only store it once in the history +\set HISTCONTROL ignoredups +\set COMP_KEYWORD_CASE upper + +-- By default, NULL displays as an empty space. Is it actually an empty +-- string, or is it null? This makes that distinction visible +\pset null '[NULL]' + +\unset QUIET diff --git a/quest/.dockerdev/Dockerfile b/quest/.dockerdev/Dockerfile new file mode 100644 index 0000000..296db59 --- /dev/null +++ b/quest/.dockerdev/Dockerfile @@ -0,0 +1,55 @@ +ARG BASE_IMAGE + +FROM ${BASE_IMAGE} + +ARG POSTGRES_VERSION +ARG RUBYGEMS_VERSION +ARG BUNDLER_VERSION + +ENV RAILS_ENV=development + +# Common dependencies +RUN apt-get update -qq \ + && apt-get dist-upgrade -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + build-essential \ + gnupg2 \ + pkg-config \ + ca-certificates \ + curl \ + wget \ + less \ + git \ + vim \ + shared-mime-info \ + && apt-get clean \ + && rm -rf /var/cache/apt/archives/* \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + + +# Postgres client +RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgres-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/postgres-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt/" bookworm-pgdg main $POSTGRES_VERSION | tee /etc/apt/sources.list.d/postgres.list > /dev/null + +# App's dependencies +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + postgresql-client-${POSTGRES_VERSION} \ + libpq-dev \ + file \ + && apt-get clean \ + && rm -rf /var/cache/apt/archives/* \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + +# Bundler +ENV LANG=C.UTF-8 \ + BUNDLE_JOBS=4 \ + BUNDLE_RETRY=3 +RUN gem update --system ${RUBYGEMS_VERSION} \ + && gem install --default bundler -v ${BUNDLER_VERSION} + +EXPOSE 3000 + +CMD ["/usr/bin/bash"] diff --git a/quest/.dockerdev/docker-compose.yml b/quest/.dockerdev/docker-compose.yml new file mode 100644 index 0000000..a70720f --- /dev/null +++ b/quest/.dockerdev/docker-compose.yml @@ -0,0 +1,112 @@ +x-environments: &environments + HISTFILE: /app/log/.bash_history + EDITOR: vim + prometheus_multiproc_dir: ./tmp + BUNDLE_APP_CONFIG: ../.bundle + DATABASE_URL: postgres://postgres:keepinsecret@postgres:5432 + REDIS_URL: redis://redis:6379 + KAFKA_BROKERS: kafka.infranet:9092 + +x-ruby: &ruby + build: + context: . + dockerfile: ./Dockerfile + args: + # Keep in sync with Gitlab CI configs + BASE_IMAGE: ruby:3.3-bookworm + POSTGRES_VERSION: '16' + RUBYGEMS_VERSION: '3.5.6' + BUNDLER_VERSION: '2.5.5' + image: quest-dev:1.0.0 + environment: + <<: *environments + tmpfs: + - /tmp + stdin_open: true + tty: true + working_dir: ${WORK_DIR:-/app} + volumes: + - ../..:/gems:cached + - ..:/app:cached + - .psqlrc:/root/.psqlrc:ro + # We store Rails cache and gems in volumes to get speed up on Docker for Mac + - rails_cache:/app/tmp/cache + - bundle:/usr/local/bundle + networks: + - default + - infranet + +x-rails-deps: &rails-deps + postgres: + condition: service_healthy + redis: + condition: service_healthy + +x-rails: &rails + <<: *ruby + depends_on: + <<: *rails-deps + +name: quest + +services: + backend: + <<: *rails + command: /bin/bash + profiles: + - donotstart + + puma: + <<: *rails + command: bundle exec puma + ports: + - '3000:3000' + + sidekiq: + <<: *rails + command: bundle exec sidekiq -C config/sidekiq.yml + + schked: + <<: *rails + command: bundle exec schked start + + outbox: + <<: *rails + command: bundle exec outbox start + + postgres: + image: postgres:16-bookworm + volumes: + - postgres:/var/lib/postgresql/data + - .psqlrc:/root/.psqlrc:ro + - ../log:/var/log/psql_history + ports: + - 5432 + environment: + POSTGRES_PASSWORD: keepinsecret + healthcheck: + test: pg_isready -U postgres -h 127.0.0.1 + interval: 10s + + redis: + image: redis:7-bookworm + environment: + ALLOW_EMPTY_PASSWORD: "yes" + volumes: + - redis:/data + ports: + - 6379 + healthcheck: + test: redis-cli ping + interval: 10s + +volumes: + bundle: + postgres: + redis: + rails_cache: + +networks: + infranet: + name: infranet + external: true diff --git a/quest/.dockerignore b/quest/.dockerignore new file mode 100644 index 0000000..c7423a3 --- /dev/null +++ b/quest/.dockerignore @@ -0,0 +1,24 @@ +# add git-ignore syntax here of things you don't want copied into docker image + +# for all code you usually don't want .git history in image, just the current commit you have checked out +.git + +# you usually don't want dockerfile and compose files in the image either +*Dockerfile* +*docker-compose* + +vendor/bundle/* +**/node_modules/* +**/log/* +**/tmp/* +!/tmp/cache/webpacker +/.idea/ +/public/assets/ +/public/system/ +/public/storage/ +/public/uploads/ +/config/master.key +/config/credentials/*.key + +# Exclude Helm chart to avoid image rebuild after deploy-related changes +/deployment/ diff --git a/quest/.env b/quest/.env new file mode 100644 index 0000000..de941f6 --- /dev/null +++ b/quest/.env @@ -0,0 +1,6 @@ +MALLOC_ARENA_MAX=2 +RAILS_ENV=development +SENTRY_DSN= +AUTHORIZATION_SERVICE_HOST= +HEALTH_CHECK_PORT=8048 +PROMETHEUS_EXPORTER_PORT=9090 diff --git a/quest/.env.development b/quest/.env.development new file mode 100644 index 0000000..c8a84d6 --- /dev/null +++ b/quest/.env.development @@ -0,0 +1,3 @@ +EAGER_LOAD=no +CODE_RELOAD=yes +CACHE=no diff --git a/quest/.env.test b/quest/.env.test new file mode 100644 index 0000000..9333b81 --- /dev/null +++ b/quest/.env.test @@ -0,0 +1,3 @@ +EAGER_LOAD=no +CODE_RELOAD=no +CACHE=no diff --git a/quest/.gitignore b/quest/.gitignore new file mode 100644 index 0000000..03a0e2b --- /dev/null +++ b/quest/.gitignore @@ -0,0 +1,68 @@ +# See https://help.github.com/articles/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. +vendor/bundle + +# Ignore IDEs +/.idea +/.direnv +.envrc + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +!/tmp/*/.keep + +# Ignore uploaded files in development. +/public/storage + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key +/public/packs +/public/packs-test +/public/manage-by-packs +/node_modules +/yarn-error.log +yarn-debug.log* +.yarn-integrity + +/.env.local +/.env.development.local +/.env.test.local +*.local.yml + +.lefthook-local/ +lefthook-local.yml + +/config/credentials/*.key +/config/credentials/local.* + +.dockerdev/docker-compose.local.yml +dip.override.yml + +# helm chart files with secrets +/deployment/**/values/*.yml +/deployment/**/values/*.yaml + +/config/credentials/local.key + +# Results of load testing +/spec/load/results/ + +# Rspec coverage +/coverage/ + +.ruby-gemset +.ruby-version +.rspec_status + +api/grpc/health.proto +.DS_Store diff --git a/quest/.irbrc b/quest/.irbrc new file mode 100644 index 0000000..53772bb --- /dev/null +++ b/quest/.irbrc @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +IRB.conf[:SAVE_HISTORY] = 1000 +IRB.conf[:HISTORY_FILE] = "#{__dir__}/log/.irb_history" +IRB.conf[:USE_AUTOCOMPLETE] = false + +ENV["XDG_DATA_HOME"] ||= "#{__dir__}/tmp" diff --git a/quest/.rspec b/quest/.rspec new file mode 100644 index 0000000..a472683 --- /dev/null +++ b/quest/.rspec @@ -0,0 +1,3 @@ +--color +--require spec_helper +--require rails_helper diff --git a/quest/.rubocop.yml b/quest/.rubocop.yml new file mode 100644 index 0000000..0e1a0b5 --- /dev/null +++ b/quest/.rubocop.yml @@ -0,0 +1,91 @@ +# We want Exclude directives from different +# config files to get merged, not overwritten +inherit_mode: + merge: + - Exclude + +inherit_gem: + standard: config/base.yml + +AllCops: + TargetRubyVersion: 3.3 + TargetRailsVersion: 7.1 + NewCops: enable + SuggestExtensions: false + +require: + - standard + - rubocop-performance + - rubocop-rails + - rubocop-rspec + +# ===== Style + +Style/SingleLineMethods: + Enabled: false + +Style/EmptyMethod: + Enabled: false + +# ===== Rails + +Rails/NotNullColumn: + Enabled: false + +Rails/UniqueValidationWithoutIndex: + Enabled: false + +Rails/SkipsModelValidations: + Enabled: false + +Rails/HasManyOrHasOneDependent: + Enabled: false + +Rails/CreateTableWithTimestamps: + Enabled: false + +Rails/UnknownEnv: + Environments: + - development + - test + - staging + - production + +Rails/RakeEnvironment: + Enabled: false + +# ===== Rspec + +RSpec/AnyInstance: + Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/LetSetup: + Enabled: false + +RSpec/StubbedMock: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/EmptyExampleGroup: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Enabled: false + +RSpec/VariableName: + Enabled: false + +RSpec/FilePath: + Exclude: + - "**/cops/**/*_spec.rb" diff --git a/quest/.schked b/quest/.schked new file mode 100644 index 0000000..4328ac4 --- /dev/null +++ b/quest/.schked @@ -0,0 +1 @@ +--require Schkedfile diff --git a/quest/CODEOWNERS b/quest/CODEOWNERS new file mode 100644 index 0000000..e69de29 diff --git a/quest/Gemfile b/quest/Gemfile new file mode 100644 index 0000000..58b359a --- /dev/null +++ b/quest/Gemfile @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +source "https://rubygems.org/" + +ruby "~> 3.3" + +gem "puma" +gem "rails", "~> 7.1" +gem "rails-i18n" +gem "pg" +gem "redis" +gem "anyway_config" +gem "jsonapi-serializer", "~> 2.0" +gem "dry-initializer" +gem "grpc" +gem "google-protobuf" +gem "nanoid", "~> 2.0" +gem "sentry-rails" +gem "sentry-ruby" +gem "sentry-sidekiq" +gem "schked" +gem "yabeda-activerecord" +gem "yabeda-prometheus-mmap" +gem "yabeda-puma-plugin" +gem "yabeda-rails" +gem "yabeda-sidekiq" +gem "yabeda-schked" +gem "yabeda-http_requests" +gem "http_health_check" +gem "strong_migrations" +gem "rails_semantic_logger" + +# Sbermarket Tech gems +gem "sbmt-outbox" +gem "sbmt-kafka_producer" + +group :development, :test do + gem "rspec" + gem "rspec_junit_formatter" + gem "rspec-rails" + gem "rspec-json_expectations" + gem "shoulda-matchers" + gem "dotenv-rails" + gem "bundler-audit" + gem "listen" + gem "factory_bot_rails" + gem "faker" +end + +group :development do + gem "rubocop-rails" + gem "rubocop-rspec" + gem "standard" + gem "grpc-tools" +end + +group :test do + gem "test-prof" +end diff --git a/quest/Gemfile.lock b/quest/Gemfile.lock new file mode 100644 index 0000000..2ae5644 --- /dev/null +++ b/quest/Gemfile.lock @@ -0,0 +1,518 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + mail (>= 2.7.1) + net-imap + net-pop + net-smtp + actionmailer (7.1.3.2) + actionpack (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activesupport (= 7.1.3.2) + mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.2) + actionpack (7.1.3.2) + actionview (= 7.1.3.2) + activesupport (= 7.1.3.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.3.2) + actionpack (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.1.3.2) + activesupport (= 7.1.3.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.3.2) + activesupport (= 7.1.3.2) + globalid (>= 0.3.6) + activemodel (7.1.3.2) + activesupport (= 7.1.3.2) + activerecord (7.1.3.2) + activemodel (= 7.1.3.2) + activesupport (= 7.1.3.2) + timeout (>= 0.4.0) + activestorage (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activesupport (= 7.1.3.2) + marcel (~> 1.0) + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + anyway_config (2.6.3) + ruby-next-core (~> 1.0) + ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.6) + builder (3.2.4) + bundler-audit (0.9.1) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + crass (1.0.6) + cutoff (0.5.2) + date (3.3.4) + diff-lcs (1.5.1) + dotenv (3.1.0) + dotenv-rails (3.1.0) + dotenv (= 3.1.0) + railties (>= 6.1) + drb (2.2.0) + ruby2_keywords + dry-core (1.0.1) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) + dry-inflector (1.0.0) + dry-initializer (3.1.1) + dry-logic (1.5.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-monads (1.6.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-struct (1.6.0) + dry-core (~> 1.0, < 2) + dry-types (>= 1.7, < 2) + ice_nine (~> 0.11) + zeitwerk (~> 2.6) + dry-types (1.7.2) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + erubi (1.12.0) + et-orbi (1.2.7) + tzinfo + exponential-backoff (0.0.4) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) + faker (3.2.3) + i18n (>= 1.8.11, < 2) + ffi (1.16.3) + fugit (1.10.0) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) + globalid (1.2.1) + activesupport (>= 6.1) + google-protobuf (3.25.3) + google-protobuf (3.25.3-aarch64-linux) + google-protobuf (3.25.3-arm64-darwin) + google-protobuf (3.25.3-x86_64-darwin) + google-protobuf (3.25.3-x86_64-linux) + googleapis-common-protos-types (1.13.0) + google-protobuf (~> 3.18) + grpc (1.62.0) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-aarch64-linux) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-arm64-darwin) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-x86_64-darwin) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-x86_64-linux) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc-tools (1.62.0) + http_health_check (0.5.0) + rack (~> 2.0) + webrick + i18n (1.14.1) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + io-console (0.7.2) + irb (1.11.2) + rdoc + reline (>= 0.4.2) + json (2.7.1) + jsonapi-serializer (2.2.0) + activesupport (>= 4.2) + karafka-core (2.3.0) + karafka-rdkafka (>= 0.14.8, < 0.15.0) + karafka-rdkafka (0.14.10) + ffi (~> 1.15) + mini_portile2 (~> 2.6) + rake (> 12) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + loofah (2.22.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.3) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + minitest (5.22.2) + mutex_m (0.2.0) + nanoid (2.0.0) + net-imap (0.4.10) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.4.0.1) + net-protocol + nio4r (2.7.0) + nokogiri (1.16.2) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.16.2-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.2-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.2-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.2-x86_64-linux) + racc (~> 1.4) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + pg (1.5.5) + prism (0.24.0) + prometheus-client-mmap (0.28.1) + rb_sys (~> 0.9) + psych (5.1.2) + stringio + puma (6.4.2) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.7.3) + rack (2.2.8.1) + rack-session (1.0.2) + rack (< 3) + rack-test (2.1.0) + rack (>= 1.3) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.3.2) + actioncable (= 7.1.3.2) + actionmailbox (= 7.1.3.2) + actionmailer (= 7.1.3.2) + actionpack (= 7.1.3.2) + actiontext (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activemodel (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + bundler (>= 1.15.0) + railties (= 7.1.3.2) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails-i18n (7.0.8) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) + rails_semantic_logger (4.14.0) + rack + railties (>= 5.1) + semantic_logger (~> 4.13) + railties (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.1.0) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + rb_sys (0.9.89) + rdoc (6.6.2) + psych (>= 4.0.0) + redis (5.1.0) + redis-client (>= 0.17.0) + redis-client (0.20.0) + connection_pool + redlock (2.0.6) + redis-client (>= 0.14.1, < 1.0.0) + regexp_parser (2.9.0) + reline (0.4.3) + io-console (~> 0.5) + rexml (3.2.6) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-json_expectations (2.2.0) + rspec-mocks (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.1) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + rspec-support (3.13.1) + rspec_junit_formatter (0.6.0) + rspec-core (>= 2, < 4, != 2.12.0) + rubocop (1.61.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.0) + parser (>= 3.3.0.4) + prism (>= 0.24.0) + rubocop-capybara (2.20.0) + rubocop (~> 1.41) + rubocop-factory_bot (2.25.1) + rubocop (~> 1.41) + rubocop-performance (1.20.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rails (2.23.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rspec (2.26.1) + rubocop (~> 1.40) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) + ruby-next-core (1.0.2) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + rufus-scheduler (3.9.1) + fugit (~> 1.1, >= 1.1.6) + sbmt-kafka_producer (2.0.0) + anyway_config (~> 2.4) + connection_pool + dry-initializer (~> 3.0) + dry-struct + waterdrop (~> 2.5) + yabeda (>= 0.11) + zeitwerk (~> 2.6) + sbmt-outbox (5.0.1) + connection_pool (~> 2.0) + cutoff (~> 0.5) + dry-initializer (~> 3.0) + dry-monads (~> 1.3) + exponential-backoff (~> 0.0) + http_health_check (~> 0.5) + rails (>= 5.2, < 8) + redis-client (>= 0.14.1, < 1.0.0) + redlock (> 1.0, < 3.0) + thor (>= 0.20, < 2) + yabeda (~> 0.8) + schked (1.3.0) + connection_pool (~> 2.0) + redlock (> 1.0, < 3.0) + rufus-scheduler (~> 3.0) + thor + semantic_logger (4.15.0) + concurrent-ruby (~> 1.0) + sentry-rails (5.16.1) + railties (>= 5.0) + sentry-ruby (~> 5.16.1) + sentry-ruby (5.16.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + sentry-sidekiq (5.16.1) + sentry-ruby (~> 5.16.1) + sidekiq (>= 3.0) + shoulda-matchers (6.1.0) + activesupport (>= 5.2.0) + sidekiq (7.2.2) + concurrent-ruby (< 2) + connection_pool (>= 2.3.0) + rack (>= 2.2.4) + redis-client (>= 0.19.0) + sniffer (0.5.0) + anyway_config (>= 1.0) + dry-initializer (~> 3) + standard (1.34.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.60) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.3.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.20.2) + stringio (3.1.0) + strong_migrations (1.7.0) + activerecord (>= 5.2) + test-prof (1.3.1) + thor (1.3.1) + timeout (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + waterdrop (2.6.14) + karafka-core (>= 2.2.3, < 3.0.0) + zeitwerk (~> 2.3) + webrick (1.8.1) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + yabeda (0.12.0) + anyway_config (>= 1.0, < 3) + concurrent-ruby + dry-initializer + yabeda-activerecord (0.1.1) + activerecord (>= 6.0) + yabeda (~> 0.6) + yabeda-http_requests (0.2.1) + sniffer + yabeda + yabeda-prometheus-mmap (0.4.0) + prometheus-client-mmap + yabeda (~> 0.10) + yabeda-puma-plugin (0.7.1) + json + puma + yabeda (~> 0.5) + yabeda-rails (0.9.0) + activesupport + anyway_config (>= 1.3, < 3) + railties + yabeda (~> 0.8) + yabeda-schked (0.2.0) + schked (>= 0.3, < 2) + yabeda (~> 0.8) + yabeda-sidekiq (0.11.0) + anyway_config (>= 1.3, < 3) + sidekiq + yabeda (~> 0.6) + zeitwerk (2.6.13) + +PLATFORMS + aarch64-linux + arm64-darwin-22 + ruby + x86_64-darwin-21 + x86_64-darwin-22 + x86_64-linux + +DEPENDENCIES + anyway_config + bundler-audit + dotenv-rails + dry-initializer + factory_bot_rails + faker + google-protobuf + grpc + grpc-tools + http_health_check + jsonapi-serializer (~> 2.0) + listen + nanoid (~> 2.0) + pg + puma + rails (~> 7.1) + rails-i18n + rails_semantic_logger + redis + rspec + rspec-json_expectations + rspec-rails + rspec_junit_formatter + rubocop-rails + rubocop-rspec + sbmt-kafka_producer + sbmt-outbox + schked + sentry-rails + sentry-ruby + sentry-sidekiq + shoulda-matchers + standard + strong_migrations + test-prof + yabeda-activerecord + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails + yabeda-schked + yabeda-sidekiq + +RUBY VERSION + ruby 3.3.0p0 + +BUNDLED WITH + 2.5.6 diff --git a/quest/Outboxfile b/quest/Outboxfile new file mode 100644 index 0000000..694f4ce --- /dev/null +++ b/quest/Outboxfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +Yabeda::Prometheus::Exporter.start_metrics_server! if defined?(Yabeda) diff --git a/quest/README.md b/quest/README.md new file mode 100644 index 0000000..1c2a4ad --- /dev/null +++ b/quest/README.md @@ -0,0 +1,28 @@ +# Quest + +This is an example application which uses: +- [sbmt-outbox](https://github.com/SberMarket-Tech/sbmt-outbox) +- [sbmt-kafka_producer](https://github.com/SberMarket-Tech/sbmt-kafka_producer) + +It allows you to learn how to use the Outbox pattern and how it works with Ruby on Rails. + +## Development + +1. Install deps and prepare DB + +```shell +dip provision +``` + +2. Run Puma server + +```shell +dip rails s +``` + +3. Run tests + +```shell +dip rake db:create db:migrate RAILS_ENV=test +dip rspec +``` diff --git a/quest/Rakefile b/quest/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/quest/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/quest/Schkedfile b/quest/Schkedfile new file mode 100644 index 0000000..7ad716f --- /dev/null +++ b/quest/Schkedfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +Rails.application.load_tasks + +Yabeda::Prometheus::Exporter.start_metrics_server! if defined?(Yabeda) + +::HttpHealthCheck.run_server_async(port: ENV.fetch("HEALTH_CHECK_PORT").to_i) diff --git a/quest/api/events/order.proto b/quest/api/events/order.proto new file mode 100644 index 0000000..0c22e16 --- /dev/null +++ b/quest/api/events/order.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "google/type/money.proto"; + +package protobuf.order_data; + +message Order { + enum OrderStatus { + PENDING = 0; + COMPLETED = 1; + CANCELED = 2; + } + + string id = 1; + string name = 2; + int32 qty = 3; + OrderStatus status = 4; + string description = 5; + google.type.Money price = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} diff --git a/quest/api/google/type/money.proto b/quest/api/google/type/money.proto new file mode 100644 index 0000000..ef41f10 --- /dev/null +++ b/quest/api/google/type/money.proto @@ -0,0 +1,43 @@ +// Copyright 2019 Google LLC. +// +// 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 +// +// http://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. +// + +syntax = "proto3"; + +package google.type; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/type/money;money"; +option java_multiple_files = true; +option java_outer_classname = "MoneyProto"; +option java_package = "com.google.type"; +option objc_class_prefix = "GTP"; + +// Represents an amount of money with its currency type. +message Money { + // The 3-letter currency code defined in ISO 4217. + string currency_code = 1; + + // The whole units of the amount. + // For example if `currencyCode` is `"USD"`, then 1 unit is one US dollar. + int64 units = 2; + + // Number of nano (10^-9) units of the amount. + // The value must be between -999,999,999 and +999,999,999 inclusive. + // If `units` is positive, `nanos` must be positive or zero. + // If `units` is zero, `nanos` can be positive, zero, or negative. + // If `units` is negative, `nanos` must be negative or zero. + // For example $-1.75 is represented as `units`=-1 and `nanos`=-750,000,000. + int32 nanos = 3; +} diff --git a/quest/app/controllers/api/v1/orders/completions_controller.rb b/quest/app/controllers/api/v1/orders/completions_controller.rb new file mode 100644 index 0000000..18e698b --- /dev/null +++ b/quest/app/controllers/api/v1/orders/completions_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api + module V1 + module Orders + class CompletionsController < ApplicationController + def create + order = Order.find_by!(uuid: params.require(:order_id)) + render_result ::Orders::CompleteOrder.call(order) + end + + def invalid_create + order = Order.find_by!(uuid: params.require(:order_id)) + + order_params = case params.require(:invalid_attr) + when "proto-payload" + {payload: params.require(:invalid_value)} + when "empty-message-key" + {empty_message_key: true} + when "empty-idempotency-key" + {empty_idempotency_key: true} + end + + render_result ::Orders::InvalidCompleteOrder.call(order, **order_params) + end + end + end + end +end diff --git a/quest/app/controllers/api/v1/orders/outbox_items_controller.rb b/quest/app/controllers/api/v1/orders/outbox_items_controller.rb new file mode 100644 index 0000000..b963f1b --- /dev/null +++ b/quest/app/controllers/api/v1/orders/outbox_items_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Api + module V1 + module Orders + class OutboxItemsController < ApplicationController + def index + order = Order.find_by!(uuid: params.require(:order_id)) + scope = order.outbox_items.order(id: :desc) + render_object scope.to_a, OrderOutboxItemSerializer + end + end + end + end +end diff --git a/quest/app/controllers/api/v1/orders_controller.rb b/quest/app/controllers/api/v1/orders_controller.rb new file mode 100644 index 0000000..6b35ed9 --- /dev/null +++ b/quest/app/controllers/api/v1/orders_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Api + module V1 + class OrdersController < ApplicationController + def show + render_object Order.find_by!(uuid: params.require(:id)) + end + + def create + render_result ::Orders::CreateOrder.call(params: new_order_params) + end + + private + + def new_order_params + params + .require(:order) + .permit(:name, :qty, :price, :description) + end + end + end +end diff --git a/quest/app/controllers/application_controller.rb b/quest/app/controllers/application_controller.rb new file mode 100644 index 0000000..2fc661d --- /dev/null +++ b/quest/app/controllers/application_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::API + private + + def render_result(result, serializer_class = nil) + if result.success? + render_object(result.success, serializer_class) + else + render_validation_errors(result.failure) + end + end + + def render_object(object, serializer_class = nil) + serializer_class ||= lookup_serializer(object) + serializer = serializer_class.new(object) + render json: serializer.serializable_hash + end + + def render_validation_errors(errors) + serializer = ValidationErrorsSerializer.new(errors, is_collection: false) + render json: serializer.serializable_hash, status: :unprocessable_entity + end + + def lookup_serializer(object) + "#{object.class.name}Serializer".constantize + end +end diff --git a/quest/app/controllers/welcome_controller.rb b/quest/app/controllers/welcome_controller.rb new file mode 100644 index 0000000..4769cc9 --- /dev/null +++ b/quest/app/controllers/welcome_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class WelcomeController < ApplicationController + def index + render json: { + it: :works, + ruby: RUBY_VERSION.to_s, + rails: Rails.version.to_s + } + end +end diff --git a/quest/app/decoders/application_decoder.rb b/quest/app/decoders/application_decoder.rb new file mode 100644 index 0000000..97dfeee --- /dev/null +++ b/quest/app/decoders/application_decoder.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ApplicationDecoder + extend Dry::Initializer + + param :data + option :message_klass + + def decode + message_klass.decode(data).to_h + end + + private + + def decode_money(value) + return unless value&.units + BigDecimal("#{value.units}.#{value.nanos}") + end + + def decode_time(value) + return unless value&.seconds + Time.zone.at(value.seconds, value.nanos, :nsec) + end +end diff --git a/quest/app/decoders/order_decoder.rb b/quest/app/decoders/order_decoder.rb new file mode 100644 index 0000000..b0f769a --- /dev/null +++ b/quest/app/decoders/order_decoder.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../../pkg/server/events/order_pb" + +class OrderDecoder < ApplicationDecoder + option :message_klass, default: -> { Protobuf::OrderData::Order } + + def decode + object = message_klass.decode(data) + + { + id: object.id, + name: object.name, + qty: object.qty, + description: object.description, + status: object.status.downcase, + price: decode_money(object.price), + updated_at: decode_time(object.updated_at), + created_at: decode_time(object.created_at) + } + end +end diff --git a/quest/app/encoders/application_encoder.rb b/quest/app/encoders/application_encoder.rb new file mode 100644 index 0000000..7f846ec --- /dev/null +++ b/quest/app/encoders/application_encoder.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ApplicationEncoder + extend Dry::Initializer + + def encode + message_class.new(data).to_proto + end + + private + + def data + raise NotimplementedError + end + + def encoder + @encoder ||= ProtobufEncoder.new(message_class: message_class) + end + + def encode_time(time) + return unless time + Google::Protobuf::Timestamp.new(seconds: time.to_i, nanos: time.nsec) + end + + def encode_money(value, currency = "RUB") + units, nanos = BigDecimal(value.to_s).to_s("F").split(".") + values = [ + currency, + units.to_i, + nanos.ljust(2, "0").to_i + ].flatten + + %i[currency_code units nanos].zip(values).to_h + end +end diff --git a/quest/app/encoders/order_encoder.rb b/quest/app/encoders/order_encoder.rb new file mode 100644 index 0000000..ff7f712 --- /dev/null +++ b/quest/app/encoders/order_encoder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "../../pkg/server/events/order_pb" + +class OrderEncoder < ApplicationEncoder + param :order + option :message_class, default: -> { Protobuf::OrderData::Order } + + private + + def data + { + id: order.uuid, + name: order.name, + qty: order.qty, + status: order.status.upcase, + description: order.description, + price: encode_money(order.price), + created_at: encode_time(order.created_at), + updated_at: encode_time(order.updated_at) + } + end +end diff --git a/quest/app/interactors/application_interactor.rb b/quest/app/interactors/application_interactor.rb new file mode 100644 index 0000000..aa9e924 --- /dev/null +++ b/quest/app/interactors/application_interactor.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ApplicationInteractor + extend Dry::Initializer + include Dry::Monads[:result, :do] + + def self.call(...) + new(...).call + end +end diff --git a/quest/app/interactors/orders/complete_order.rb b/quest/app/interactors/orders/complete_order.rb new file mode 100644 index 0000000..2d1a650 --- /dev/null +++ b/quest/app/interactors/orders/complete_order.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Orders + class CompleteOrder < ApplicationInteractor + param :order + + def call + order.transaction do + order.completed! + create_outbox_item + end + + Success(order) + rescue ActiveRecord::RecordInvalid + Failure(order.errors) + end + + private + + def create_outbox_item + Sbmt::Outbox::CreateOutboxItem.call( + OrderOutboxItem, + attributes: outbox_item_attrs + ) + end + + def outbox_item_attrs + { + event_key: order.uuid, + options: {key: order.uuid}, + payload: OrderEncoder.new(order).encode + } + end + end +end diff --git a/quest/app/interactors/orders/create_order.rb b/quest/app/interactors/orders/create_order.rb new file mode 100644 index 0000000..50c3c0b --- /dev/null +++ b/quest/app/interactors/orders/create_order.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Orders + class CreateOrder < ApplicationInteractor + option :params + + def call + order = yield persist + Success(order) + end + + private + + def persist + order = Order.new(params) + + if order.save + Success(order) + else + Failure(order.errors) + end + end + end +end diff --git a/quest/app/interactors/orders/invalid_complete_order.rb b/quest/app/interactors/orders/invalid_complete_order.rb new file mode 100644 index 0000000..d2bc9ea --- /dev/null +++ b/quest/app/interactors/orders/invalid_complete_order.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Orders + class InvalidCompleteOrder < ApplicationInteractor + param :order + option :payload, optional: true + option :empty_message_key, default: -> { false } + option :empty_idempotency_key, default: -> { false } + + def call + order.transaction do + order.completed! + create_outbox_item + end + + Success(order) + rescue ActiveRecord::RecordInvalid + Failure(order.errors) + end + + private + + def create_outbox_item + Sbmt::Outbox::CreateOutboxItem.call( + OrderOutboxItem, + attributes: outbox_item_attrs, + partition_by: order.uuid + ) + end + + def outbox_item_attrs + headers = if empty_idempotency_key + {Sbmt::Outbox::OutboxItem::IDEMPOTENCY_HEADER_NAME => ""} + else + {} + end + + { + event_key: order.uuid, + options: { + key: empty_message_key ? nil : order.uuid, + headers: headers + }.compact, + payload: payload || OrderEncoder.new(order).encode + } + end + end +end diff --git a/quest/app/lib/error_tracker.rb b/quest/app/lib/error_tracker.rb new file mode 100644 index 0000000..71b63fc --- /dev/null +++ b/quest/app/lib/error_tracker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ErrorTracker + LEVELS = %i[info error debug warning fatal].freeze + + class << self + LEVELS.each do |level| + define_method(level) do |message, **args| + notify(message, level: level, **args) + end + end + + def notify(message, level: :error, tags: {}, **args) + Sentry.with_scope do |scope| + scope.set_tags(tags) + + case message + when String + Sentry.capture_message(message, level: level, extra: args) + else + Sentry.capture_exception(message, level: level, extra: args) + end + end + end + end +end diff --git a/quest/app/models/application_record.rb b/quest/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/quest/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/quest/app/models/order.rb b/quest/app/models/order.rb new file mode 100644 index 0000000..9e208f0 --- /dev/null +++ b/quest/app/models/order.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Order < ApplicationRecord + enum :status, { + pending: "pending", + completed: "completed", + canceled: "canceled" + } + + has_many :outbox_items, class_name: "OrderOutboxItem", foreign_key: :event_key, primary_key: :uuid, inverse_of: :order + + validates :name, :qty, :price, presence: true + + after_initialize do + self.uuid ||= Nanoid.generate(size: 12) + end +end diff --git a/quest/app/models/order_outbox_item.rb b/quest/app/models/order_outbox_item.rb new file mode 100644 index 0000000..66ec904 --- /dev/null +++ b/quest/app/models/order_outbox_item.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class OrderOutboxItem < Sbmt::Outbox::OutboxItem + belongs_to :order, foreign_key: :event_key, primary_key: :uuid, optional: true, inverse_of: :outbox_items +end diff --git a/quest/app/serializers/application_serializer.rb b/quest/app/serializers/application_serializer.rb new file mode 100644 index 0000000..ef4e461 --- /dev/null +++ b/quest/app/serializers/application_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationSerializer + include JSONAPI::Serializer +end diff --git a/quest/app/serializers/order_outbox_item_serializer.rb b/quest/app/serializers/order_outbox_item_serializer.rb new file mode 100644 index 0000000..d89a746 --- /dev/null +++ b/quest/app/serializers/order_outbox_item_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class OrderOutboxItemSerializer < ApplicationSerializer + set_id :uuid + + attributes :event_key, + :bucket, + :status, + :options, + :errors_count, + :error_log, + :processed_at, + :created_at, + :updated_at + + attribute :payload do |object| + OrderDecoder.new(object.payload).decode + end +end diff --git a/quest/app/serializers/order_serializer.rb b/quest/app/serializers/order_serializer.rb new file mode 100644 index 0000000..e9e5b1e --- /dev/null +++ b/quest/app/serializers/order_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class OrderSerializer < ApplicationSerializer + set_id :uuid + + attributes :name, + :qty, + :status, + :price, + :description, + :created_at, + :updated_at +end diff --git a/quest/app/serializers/validation_errors_serializer.rb b/quest/app/serializers/validation_errors_serializer.rb new file mode 100644 index 0000000..25a4afa --- /dev/null +++ b/quest/app/serializers/validation_errors_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ValidationErrorsSerializer < ApplicationSerializer + set_id { nil } + + attribute :full_messages do |object| + object.full_messages + end + + attribute :details do |object| + object.details + end +end diff --git a/quest/bin/grpc_gen b/quest/bin/grpc_gen new file mode 100755 index 0000000..e1170d2 --- /dev/null +++ b/quest/bin/grpc_gen @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +for file in "$@" +do + if [[ $(dirname $file) == *deps* ]] + then + outdir="pkg/client" + else + outdir="pkg/server" + fi + + echo "Generating gRPC code from $file to $outdir" + + bundle exec grpc_tools_ruby_protoc \ + -I api -I deps/services -I deps/googleapis \ + --ruby_out="$outdir" --grpc_out="$outdir" \ + "$file" +done diff --git a/quest/bin/rails b/quest/bin/rails new file mode 100755 index 0000000..6fb4e40 --- /dev/null +++ b/quest/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/quest/bin/rake b/quest/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/quest/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/quest/bin/setup b/quest/bin/setup new file mode 100755 index 0000000..d4d0c35 --- /dev/null +++ b/quest/bin/setup @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts '== Installing dependencies ==' + system('bundle check') || system!('bundle install') + + # puts "\n== Copying sample files ==" + # unless File.exist?('config/database.yml') + # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' + # end + + puts "\n== Preparing database ==" + system! 'bin/rails db:prepare db:test:prepare' + + 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/quest/build/Dockerfile b/quest/build/Dockerfile new file mode 100644 index 0000000..18639e1 --- /dev/null +++ b/quest/build/Dockerfile @@ -0,0 +1,63 @@ +ARG BUILD_BASE_IMAGE_REGISTRY +ARG BUILD_BASE_IMAGE_NAME +ARG BUILD_BASE_IMAGE_TAG +ARG BUILD_DEPENDENCIES + +# =============== Base image =============== +FROM ${BUILD_BASE_IMAGE_REGISTRY}/${BUILD_BASE_IMAGE_NAME}:${BUILD_BASE_IMAGE_TAG} as base-image + +ARG APP_PATH=/app +ARG RUBYGEMS_VERSION=3.5.6 +ARG BUNDLER_VERSION=2.5.5 + +ENV LANG=C.UTF-8 +ENV BUNDLE_PATH=${APP_PATH}/bundle +ENV GEM_HOME=${APP_PATH}/bundle + +WORKDIR $APP_PATH + +COPY Gemfile Gemfile.lock ./ + +RUN gem update --system ${RUBYGEMS_VERSION} \ + && gem install --default bundler -v ${BUNDLER_VERSION} + +RUN chown -R application:application /app && \ + bundle config --local deployment 'true' && \ + bundle config --local path "${BUNDLE_PATH}" && \ + bundle config --local clean 'true' && \ + bundle config --local no-cache 'true' + +# =============== Production image =============== +FROM base-image as bundle-prod + +ENV RAILS_ENV=production + +RUN bundle install \ + --without development test \ + --jobs=8 \ + --retry=3 + +# =============== Test image =============== +FROM base-image as bundle-development + +ENV RAILS_ENV=test + +RUN bundle install \ + --jobs=8 \ + --retry=3 + +# =============== Final image =============== +FROM bundle-${BUILD_DEPENDENCIES} AS final + +COPY . $APP_PATH +COPY --chown=application . $APP_PATH + +USER 10001 + +ENV PATH="${APP_PATH}/bin:${PATH}" + +ARG GIT_TAG=0.0.0-dev +ARG BUILD=HEAD + +ENV GIT_TAG=$GIT_TAG +ENV BUILD=$BUILD diff --git a/quest/config.ru b/quest/config.ru new file mode 100644 index 0000000..68dedaa --- /dev/null +++ b/quest/config.ru @@ -0,0 +1,4 @@ +require ::File.expand_path("../config/environment", __FILE__) + +::HttpHealthCheck.run_server_async(port: ENV.fetch("HEALTH_CHECK_PORT").to_i) +run Rails.application diff --git a/quest/config/application.rb b/quest/config/application.rb new file mode 100644 index 0000000..9d3ce47 --- /dev/null +++ b/quest/config/application.rb @@ -0,0 +1,34 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +# require "active_job/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "active_job/railtie" +# require "action_mailer/railtie" +# require "action_view/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +# https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +if %w[development test].include?(ENV["RAILS_ENV"]) + Dotenv::Rails.load +end + +module Quest + class Application < Rails::Application + config.load_defaults 7.1 + + config.api_only = true + config.time_zone = "Moscow" + + config.i18n.available_locales = [:en] + config.i18n.default_locale = :en + + config.active_job.queue_adapter = :sidekiq + end +end diff --git a/quest/config/boot.rb b/quest/config/boot.rb new file mode 100644 index 0000000..2820116 --- /dev/null +++ b/quest/config/boot.rb @@ -0,0 +1,3 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/quest/config/configs/application_config.rb b/quest/config/configs/application_config.rb new file mode 100644 index 0000000..d4bd6d8 --- /dev/null +++ b/quest/config/configs/application_config.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Base class for application config classes +class ApplicationConfig < Anyway::Config + class << self + # Make it possible to access a singleton config instance + # via class methods (i.e., without explicitly calling `instance`) + delegate_missing_to :instance + + private + + # Returns a singleton config instance + def instance + @instance ||= new + end + end +end diff --git a/quest/config/configs/redis_config.rb b/quest/config/configs/redis_config.rb new file mode 100644 index 0000000..c18cf18 --- /dev/null +++ b/quest/config/configs/redis_config.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "uri" + +class RedisConfig < ApplicationConfig + config_name :redis + env_prefix :redis + + attr_config :url, :db, :db_sidekiq, :db_outbox, :db_cache + + required :url, :db, :db_sidekiq, :db_outbox, :db_cache + + def connection_uri(db_name = :db) + uri = URI(dsn) + uri.path = "/#{public_send(db_name)}" + uri.to_s + end + + def connection_options(db_name = :db) + { + url: connection_uri(db_name), + reconnect_attempts: [0, 0.05, 0.1, 0.5], + db: public_send(db_name) + } + end +end diff --git a/quest/config/database.yml b/quest/config/database.yml new file mode 100644 index 0000000..8b8d34b --- /dev/null +++ b/quest/config/database.yml @@ -0,0 +1,24 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %> + prepared_statements: false + advisory_locks: false + url: <%= ENV["DATABASE_URL"] %> + +development: + <<: *default + database: quest_development + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: quest_test + +staging: + <<: *default + +production: + <<: *default diff --git a/quest/config/environment.rb b/quest/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/quest/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/quest/config/environments/development.rb b/quest/config/environments/development.rb new file mode 100644 index 0000000..73fd67a --- /dev/null +++ b/quest/config/environments/development.rb @@ -0,0 +1,77 @@ +require "active_support/core_ext/integer/time" + +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 any time + # it changes. 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 = ENV["CODE_RELOAD"] == "no" + + # Do not eager load code on boot. + config.eager_load = ENV["EAGER_LOAD"] == "yes" + + # 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 ENV["CACHE"] == "yes" + config.cache_store = :redis_cache_store, {url: RedisConfig.connection_uri, expires_in: 5.minutes} + 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 + + # 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 exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "debug").to_s.downcase + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Use an evented file watcher to asynchronously detect changes in source code, + # routes, locales, etc. This feature depends on the listen gem. + unless ENV["DEV_SKIP_LISTEN_GEM"] + config.file_watcher = ActiveSupport::EventedFileUpdateChecker + end + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + config.rails_semantic_logger.semantic = false + config.rails_semantic_logger.started = true + config.rails_semantic_logger.processing = true + config.rails_semantic_logger.rendered = true + + config.semantic_logger.add_appender( + io: $stdout, + level: config.log_level, + formatter: config.rails_semantic_logger.format + ) +end diff --git a/quest/config/environments/production.rb b/quest/config/environments/production.rb new file mode 100644 index 0000000..a0e71d4 --- /dev/null +++ b/quest/config/environments/production.rb @@ -0,0 +1,98 @@ +require "active_support/core_ext/integer/time" + +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 + + # 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? + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.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 + + # 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 + + # Include generic and useful information about system operation, but avoid logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info").to_s.downcase + + # Use a different cache store in production. + config.cache_store = :redis_cache_store, {url: RedisConfig.connection_uri, expires_in: 1.hour} + + # 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 = Rails.application.config.sbmt_app.manifest.fetch(:name) + + # 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 + + # Log disallowed deprecations. + config.active_support.disallowed_deprecation = :log + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Use a different logger for distributed setups. + # require "syslog/logger" + # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Inserts middleware to perform automatic connection switching. + # The `database_selector` hash is used to pass options to the DatabaseSelector + # middleware. The `delay` is used to determine how long to wait after a write + # to send a subsequent read to the primary. + # + # The `database_resolver` class is used by the middleware to determine which + # database is appropriate to use based on the time delay. + # + # The `database_resolver_context` class is used by the middleware to set + # timestamps for the last write to the primary. The resolver uses the context + # class timestamps to determine how long to wait before reading from the + # replica. + # + # By default Rails will store a last write timestamp in the session. The + # DatabaseSelector middleware is designed as such you can define your own + # strategy for connection switching and pass that into the middleware through + # these configuration options. + # config.active_record.database_selector = { delay: 2.seconds } + # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver + # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session +end diff --git a/quest/config/environments/staging.rb b/quest/config/environments/staging.rb new file mode 100644 index 0000000..ad87d7e --- /dev/null +++ b/quest/config/environments/staging.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require_relative "production" + +# Rails.application.configure do +# # paste custom config +# end diff --git a/quest/config/environments/test.rb b/quest/config/environments/test.rb new file mode 100644 index 0000000..fde0bff --- /dev/null +++ b/quest/config/environments/test.rb @@ -0,0 +1,57 @@ +require "active_support/core_ext/integer/time" + +# 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! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + config.cache_classes = ENV["CODE_RELOAD"] == "no" + # config.action_view.cache_template_loading = 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 = ENV["EAGER_LOAD"] == "yes" + + # 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 + config.cache_store = :null_store + + # 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 + + # 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 + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true +end diff --git a/quest/config/initializers/0_redis.rb b/quest/config/initializers/0_redis.rb new file mode 100644 index 0000000..6736b41 --- /dev/null +++ b/quest/config/initializers/0_redis.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +PRIMARY_REDIS = ConnectionPool::Wrapper.new(size: ENV.fetch("DATABASE_POOL_SIZE", 5).to_i) do + Redis.new(RedisConfig.connection_options(:db)) +end diff --git a/quest/config/initializers/backtrace_silencers.rb b/quest/config/initializers/backtrace_silencers.rb new file mode 100644 index 0000000..ec1faa6 --- /dev/null +++ b/quest/config/initializers/backtrace_silencers.rb @@ -0,0 +1 @@ +Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] diff --git a/quest/config/initializers/filter_parameter_logging.rb b/quest/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..6138714 --- /dev/null +++ b/quest/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,3 @@ +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/quest/config/initializers/http_health_check.rb b/quest/config/initializers/http_health_check.rb new file mode 100644 index 0000000..992a16f --- /dev/null +++ b/quest/config/initializers/http_health_check.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# TODO: Move to sbmt-app? +HttpHealthCheck.configure do |c| + HttpHealthCheck.add_builtin_probes(c) + + c.probe "/readiness/puma" do |env| + [200, {}, [Puma.stats]] + end + + c.probe "/readiness/schked" do |env| + [200, {}, [Schked.worker.send(:scheduler).uptime_s]] + end +end diff --git a/quest/config/initializers/outbox.rb b/quest/config/initializers/outbox.rb new file mode 100644 index 0000000..a32b31b --- /dev/null +++ b/quest/config/initializers/outbox.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.config.outbox.tap do |config| + config.redis = RedisConfig.connection_options(:db_outbox) +end diff --git a/quest/config/initializers/schked.rb b/quest/config/initializers/schked.rb new file mode 100644 index 0000000..273abe4 --- /dev/null +++ b/quest/config/initializers/schked.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Schked.config.tap do |config| + config.redis = RedisConfig.connection_options(:db) +end diff --git a/quest/config/initializers/sentry.rb b/quest/config/initializers/sentry.rb new file mode 100644 index 0000000..bf44f14 --- /dev/null +++ b/quest/config/initializers/sentry.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.breadcrumbs_logger = %i[active_support_logger http_logger] + config.enabled_environments = %w[staging production] + config.excluded_exceptions += %w[ActionController::RoutingError] + config.traces_sample_rate = 1.0 + config.release = ENV["APP_VERSION"] +end diff --git a/quest/config/initializers/sidekiq.rb b/quest/config/initializers/sidekiq.rb new file mode 100644 index 0000000..c7c9fc7 --- /dev/null +++ b/quest/config/initializers/sidekiq.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "sidekiq/web" + +Sidekiq.configure_server do |config| + config.redis = RedisConfig.connection_options(:db_sidekiq) + + ::HttpHealthCheck.run_server_async(port: ENV.fetch("HEALTH_CHECK_PORT").to_i) +end + +Sidekiq.configure_client do |config| + config.redis = RedisConfig.connection_options(:db_sidekiq) +end diff --git a/quest/config/initializers/wrap_parameters.rb b/quest/config/initializers/wrap_parameters.rb new file mode 100644 index 0000000..8b64a78 --- /dev/null +++ b/quest/config/initializers/wrap_parameters.rb @@ -0,0 +1,3 @@ +ActiveSupport.on_load(:action_controller) do + wrap_parameters format: [:json] +end diff --git a/quest/config/initializers/yabeda.rb b/quest/config/initializers/yabeda.rb new file mode 100644 index 0000000..a547fee --- /dev/null +++ b/quest/config/initializers/yabeda.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Yabeda.configure do + default_tag :rails_environment, Rails.env + default_tag :service_name, "Quest" +end diff --git a/quest/config/kafka_producer.yml b/quest/config/kafka_producer.yml new file mode 100644 index 0000000..b30683a --- /dev/null +++ b/quest/config/kafka_producer.yml @@ -0,0 +1,33 @@ +default: &default + deliver: true + wait_on_queue_full: true + max_payload_size: 1000012 + max_wait_timeout: 5 + wait_timeout: 0.005 + ignore_kafka_error: <%= ENV.fetch('QUEST__KAFKA__IGNORE_KAFKA_ERRORS') { 'true' } %> + auth: + kind: 'sasl_plaintext' + sasl_mechanism: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').first %> + sasl_username: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').second %> + sasl_password: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').last %> + kafka: + servers: <%= ENV.fetch('KAFKA_BROKERS'){ 'localhost:9092' } %> + max_retries: <%= ENV.fetch('QUEST__KAFKA__PRODUCER__MAX_RETRIES') { 2 }%> + required_acks: <%= ENV.fetch('QUEST__KAFKA__PRODUCER_REQUIRED_ACKS') { -1 }%> + ack_timeout: <%= ENV.fetch('QUEST__KAFKA__PRODUCER_ACK_TIMEOUT') { 1 }%> + retry_backoff: <%= ENV.fetch('QUEST__KAFKA__PRODUCER_RETRY_BACKOFF') { 1 }%> + connect_timeout: <%= ENV.fetch('QUEST__KAFKA__PRODUCER_CONNECT_TIMEOUT') { 1 }%> +development: + <<: *default + auth: + kind: 'plaintext' +test: + <<: *default + deliver: false + wait_on_queue_full: false + auth: + kind: 'plaintext' +staging: &staging + <<: *default +production: + <<: *staging diff --git a/quest/config/outbox.yml b/quest/config/outbox.yml new file mode 100644 index 0000000..1dcfc2f --- /dev/null +++ b/quest/config/outbox.yml @@ -0,0 +1,31 @@ +default: &default + bucket_size: 16 + probes: + port: <%= ENV.fetch("HEALTH_CHECK_PORT"){ 5555 } %> + + outbox_items: + order_outbox_item: + partition_size: <%= ENV.fetch('QUEST__ORDER_OUTBOX_ITEM__PARTITION_SIZE'){ '2' } %> + partition_strategy: hash + retention: P3D + max_retries: <%= ENV.fetch('QUEST__ORDER_OUTBOX_ITEM__MAX_RETRIES'){ '7' } %> + retry_strategies: + - exponential_backoff + - compacted_log + transports: + sbmt/kafka_producer: + topic: <%= ENV.fetch('QUEST__KAFKA__TOPICS__ORDERS'){ 'yc.quest-stand.orders.0' } %> + +development: + <<: *default + +test: + <<: *default + bucket_size: 2 + +staging: + <<: *default + +production: + <<: *default + bucket_size: 128 diff --git a/quest/config/puma.rb b/quest/config/puma.rb new file mode 100644 index 0000000..bce93a8 --- /dev/null +++ b/quest/config/puma.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +app_root = File.expand_path(File.join(File.dirname(__FILE__), "..")) + +# 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. +# +env = ENV.fetch("RAILS_ENV", "development") +# bind ENV.fetch("PUMA_SOCKET", "unix://#{app_root}/tmp/sockets/puma.sock") + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +workers ENV.fetch("PUMA_CONCURRENCY", (env == "development") ? 0 : 10).to_i +worker_timeout (env == "production") ? 3600 : 10 + +threads_count = ENV.fetch("RAILS_MAX_THREADS", 2).to_i +threads(threads_count, threads_count) + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PUMA_PORT", 3000) + +# Specifies the `environment` that Puma will run in. +# +environment env + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PUMA_PIDFILE", "#{app_root}/tmp/pids/puma.pid") +state_path ENV.fetch("PUMA_STATEFILE", "#{app_root}/tmp/pids/puma.state") + +# 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! + +on_worker_boot do + SemanticLogger.reopen +end + +# Allow puma to be restarted by `bin/rails restart` command. +activate_control_app ENV.fetch("PUMA_CTL_SOCKET", "unix:///var/run/pumactl.sock") +plugin :tmp_restart +plugin :yabeda +plugin :yabeda_prometheus diff --git a/quest/config/redis.yml b/quest/config/redis.yml new file mode 100644 index 0000000..b25efdf --- /dev/null +++ b/quest/config/redis.yml @@ -0,0 +1,20 @@ +default: &default + db: 0 + db_sidekiq: 1 + db_cache: 2 + db_outbox: 3 + +development: + <<: *default + +test: + db: 15 + db_sidekiq: 15 + db_outbox: 15 + db_cache: 15 + +staging: + <<: *default + +production: + <<: *default diff --git a/quest/config/routes.rb b/quest/config/routes.rb new file mode 100644 index 0000000..0da9fff --- /dev/null +++ b/quest/config/routes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + root "welcome#index" + + namespace :api do + namespace :v1 do + resources :orders, only: [:show, :create] do + scope module: "orders" do + resource :completion, only: :create do + member do + post :invalid_create + end + end + resources :outbox_items, only: [:index] + end + end + end + end +end diff --git a/quest/config/schedule.rb b/quest/config/schedule.rb new file mode 100644 index 0000000..fad27b2 --- /dev/null +++ b/quest/config/schedule.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Schked schedule https://github.com/bibendi/schked +# WARNING: Keep your tasks as fast as you can. The best choise are Sidekiq jobs. + +# Example task +# cron "*/1 * * * *", as: "UpdateMerchantStatusRequestsJob", timeout: "60s", overlap: false do +# UpdateMerchantStatusRequestsJob.perform_async +# end diff --git a/quest/config/sidekiq.yml b/quest/config/sidekiq.yml new file mode 100644 index 0000000..07ba059 --- /dev/null +++ b/quest/config/sidekiq.yml @@ -0,0 +1,4 @@ +:concurrency: <%= ENV.fetch('RAILS_MAX_THREADS', 4).to_i %> +:queues: + - default + - outbox diff --git a/quest/db/migrate/20230502135214_create_orders.rb b/quest/db/migrate/20230502135214_create_orders.rb new file mode 100644 index 0000000..51f307e --- /dev/null +++ b/quest/db/migrate/20230502135214_create_orders.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateOrders < ActiveRecord::Migration[7.0] + def change + create_enum :order_status, %w[pending completed canceled] + + create_table :orders do |t| + t.string :name, null: false + t.integer :qty, null: false + t.enum :status, enum_type: :order_status, null: false, default: :pending + t.decimal :price, precision: 10, scale: 2, null: false + t.text :description + t.timestamps null: false + end + end +end diff --git a/quest/db/migrate/20230503142323_create_order_outbox_items.rb b/quest/db/migrate/20230503142323_create_order_outbox_items.rb new file mode 100644 index 0000000..27c43bf --- /dev/null +++ b/quest/db/migrate/20230503142323_create_order_outbox_items.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateOrderOutboxItems < ActiveRecord::Migration[7.0] + def change + create_table :order_outbox_items do |t| + t.string :uuid, null: false + t.string :event_key, null: false + t.integer :bucket, null: false + t.integer :status, null: false, default: 0 + t.jsonb :options + t.binary :payload, null: false + t.integer :errors_count, null: false, default: 0 + t.text :error_log + t.timestamp :processed_at + t.timestamps null: false + end + + add_index :order_outbox_items, :uuid, unique: true + add_index :order_outbox_items, [:status, :bucket] + add_index :order_outbox_items, :event_key + add_index :order_outbox_items, :created_at + end +end diff --git a/quest/db/migrate/20230512120120_add_uuid_to_orders.rb b/quest/db/migrate/20230512120120_add_uuid_to_orders.rb new file mode 100644 index 0000000..99e522f --- /dev/null +++ b/quest/db/migrate/20230512120120_add_uuid_to_orders.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddUuidToOrders < ActiveRecord::Migration[7.0] + def change + add_column :orders, :uuid, :string, null: false + + safety_assured { add_index :orders, :uuid, unique: true } + end +end diff --git a/quest/db/schema.rb b/quest/db/schema.rb new file mode 100644 index 0000000..b05f324 --- /dev/null +++ b/quest/db/schema.rb @@ -0,0 +1,51 @@ +# 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[7.1].define(version: 2023_05_12_120120) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + # Custom types defined in this database. + # Note that some types may not work with other database engines. Be careful if changing database. + create_enum "order_status", ["pending", "completed", "canceled"] + + create_table "order_outbox_items", force: :cascade do |t| + t.string "uuid", null: false + t.string "event_key", null: false + t.integer "bucket", null: false + t.integer "status", default: 0, null: false + t.jsonb "options" + t.binary "payload", null: false + t.integer "errors_count", default: 0, null: false + t.text "error_log" + t.datetime "processed_at", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_order_outbox_items_on_created_at" + t.index ["event_key"], name: "index_order_outbox_items_on_event_key" + t.index ["status", "bucket"], name: "index_order_outbox_items_on_status_and_bucket" + t.index ["uuid"], name: "index_order_outbox_items_on_uuid", unique: true + end + + create_table "orders", force: :cascade do |t| + t.string "name", null: false + t.integer "qty", null: false + t.enum "status", default: "pending", null: false, enum_type: "order_status" + t.decimal "price", precision: 10, scale: 2, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "uuid", null: false + t.index ["uuid"], name: "index_orders_on_uuid", unique: true + end + +end diff --git a/quest/db/seeds.rb b/quest/db/seeds.rb new file mode 100644 index 0000000..2bc55ca --- /dev/null +++ b/quest/db/seeds.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require_relative "../lib/seeds/dsl" +using Seeds::DSL + +records = {} + +announce "Create orders" do + records[:pending_orders] = create_batch 5, :order + records[:completed_orders] = create_batch 5, :order, :completed + records[:canceled_orders] = create_batch 5, :order, :canceled +end diff --git a/quest/dip.yml b/quest/dip.yml new file mode 100644 index 0000000..b78479f --- /dev/null +++ b/quest/dip.yml @@ -0,0 +1,76 @@ +version: '7.5' + +environment: + WORK_DIR: /app/${DIP_WORK_DIR_REL_PATH} + +compose: + files: + - ./.dockerdev/docker-compose.yml + - ./.dockerdev/docker-compose.local.yml + +interaction: + bash: + description: Open a Bash shell + service: backend + command: bash + + bundle: + description: Run Bundler commands + service: backend + command: bundle + + rake: + description: Run Rake commands + service: backend + command: bundle exec rake + + rails: + description: Run Rails commands + service: backend + command: bundle exec rails + subcommands: + s: + description: Run puma available at http://localhost:3000 + service: puma + command: bundle exec puma + compose: + run_options: [service-ports, use-aliases] + + rspec: + description: Run RSpec commands within test environment + service: backend + environment: + RAILS_ENV: test + command: bundle exec rspec + + rubocop: + description: Lint ruby files + service: backend + command: bundle exec rubocop + + psql: + description: Open Postgres console + service: postgres + default_args: quest_development + command: env PGPASSWORD=keepinsecret psql -h postgres -U postgres + + redis: + description: Open a Redis console + service: redis + command: redis-cli -h redis + + grpc_gen: + description: Generate services from .proto files + service: backend + command: bash -c 'bin/grpc_gen $@' 'grpc_gen' + + setup: + description: Install deps + service: backend + command: bin/setup + +provision: + - cp -f lefthook-local.dip_example.yml lefthook-local.yml + - touch .env.local + - dip compose down --volumes + - dip setup diff --git a/quest/lefthook-local.dip_example.yml b/quest/lefthook-local.dip_example.yml new file mode 100644 index 0000000..2b1f70e --- /dev/null +++ b/quest/lefthook-local.dip_example.yml @@ -0,0 +1,4 @@ +pre-commit: + commands: + rubocop: + run: dip {cmd} diff --git a/quest/lefthook.yml b/quest/lefthook.yml new file mode 100644 index 0000000..365d158 --- /dev/null +++ b/quest/lefthook.yml @@ -0,0 +1,6 @@ +pre-commit: + commands: + rubocop: + tags: backend + glob: "**/*.rb" + run: bundle exec rubocop -A --force-exclusion {staged_files} && git add {staged_files} diff --git a/quest/lib/seeds/dsl.rb b/quest/lib/seeds/dsl.rb new file mode 100644 index 0000000..453f198 --- /dev/null +++ b/quest/lib/seeds/dsl.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Seeds + SPACE = " " + + class << self + attr_accessor :depth + + def announce(msg) + $stdout.puts pad(msg) + + return unless block_given? + + self.depth += 1 + yield + self.depth -= 1 + end + + def pad(msg) + return msg if depth.zero? + + "#{SPACE * depth * 2} ↳ #{msg}" + end + + def pretty_params(params) + params.each_with_object([]) do |(key, val), acc| + acc << if val.is_a?(ActiveRecord::Base) + "#{key}: #{val.class.model_name.human}(##{val.id})" + else + "#{key}: #{val}" + end + end.join(", ") + end + end + + self.depth = 0 + + module DSL + refine Object do + def announce(msg, &block) + Seeds.announce(msg, &block) + end + + def create(factory, *traits, **params) + FactoryBot.create(factory, *traits, **params).tap do |record| + traits_msg = traits.any? ? " (#{traits.join(", ")})" : "" + params_msg = params.any? ? " with #{Seeds.pretty_params(params)}" : "" + + announce "created #{factory.to_s.camelize}(##{record.id})#{traits_msg}#{params_msg}" + end + end + + def create_batch(batch_size, factory, *traits, **params) + created = [] + batch_size.times do |n| + nth_params = params.clone + nth_params.keys.each do |k| + next unless nth_params[k].is_a?(String) + nth_params[k] = nth_params[k].gsub("%%", "%03d" % n) + end + created << FactoryBot.create(factory, *traits, **nth_params) + end + traits_msg = traits.any? ? " (#{traits.join(", ")})" : "" + params_msg = params.any? ? " with #{Seeds.pretty_params(params)}" : "" + + announce "created batch of #{created.size} #{factory.to_s.camelize}s#{traits_msg}#{params_msg}" + created + end + end + end +end diff --git a/quest/log/.keep b/quest/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/quest/pkg/client/.keep b/quest/pkg/client/.keep new file mode 100644 index 0000000..e69de29 diff --git a/quest/pkg/server/.keep b/quest/pkg/server/.keep new file mode 100644 index 0000000..e69de29 diff --git a/quest/pkg/server/events/order_pb.rb b/quest/pkg/server/events/order_pb.rb new file mode 100644 index 0000000..ee0e8c5 --- /dev/null +++ b/quest/pkg/server/events/order_pb.rb @@ -0,0 +1,34 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: events/order.proto + +require "google/protobuf" + +require "google/protobuf/timestamp_pb" +require "google/type/money_pb" + +Google::Protobuf::DescriptorPool.generated_pool.build do + add_file("events/order.proto", syntax: :proto3) do + add_message "protobuf.order_data.Order" do + optional :id, :string, 1 + optional :name, :string, 2 + optional :qty, :int32, 3 + optional :status, :enum, 4, "protobuf.order_data.Order.OrderStatus" + optional :description, :string, 5 + optional :price, :message, 6, "google.type.Money" + optional :created_at, :message, 7, "google.protobuf.Timestamp" + optional :updated_at, :message, 8, "google.protobuf.Timestamp" + end + add_enum "protobuf.order_data.Order.OrderStatus" do + value :PENDING, 0 + value :COMPLETED, 1 + value :CANCELED, 2 + end + end +end + +module Protobuf + module OrderData + Order = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("protobuf.order_data.Order").msgclass + Order::OrderStatus = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("protobuf.order_data.Order.OrderStatus").enummodule + end +end diff --git a/quest/public/robots.txt b/quest/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/quest/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/quest/spec/controllers/api/v1/orders/completions_controller_spec.rb b/quest/spec/controllers/api/v1/orders/completions_controller_spec.rb new file mode 100644 index 0000000..df7bb4a --- /dev/null +++ b/quest/spec/controllers/api/v1/orders/completions_controller_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +describe Api::V1::Orders::CompletionsController do + let_it_be(:order, reload: true) { create(:order) } + + describe "#create" do + context "when success" do + it "completes the order" do + post :create, params: {order_id: order.uuid} + + expect(response).to be_successful + expect(order.reload).to be_completed + end + end + + context "when failure" do + it "doesn't complete the order" do + order.name = nil + expect(Order).to receive(:find_by!).and_return(order) + + post :create, params: {order_id: order.uuid} + + expect(response).to have_http_status(:unprocessable_entity) + expect(order.reload).not_to be_completed + end + end + end + + describe "#invalid_create" do + context "when success" do + it "completes the order" do + post :invalid_create, params: {order_id: order.uuid, invalid_attr: "proto-payload", invalid_value: "bad-value"} + + expect(response).to be_successful + expect(order.reload).to be_completed + end + end + end +end diff --git a/quest/spec/controllers/api/v1/orders/outbox_items_controller_spec.rb b/quest/spec/controllers/api/v1/orders/outbox_items_controller_spec.rb new file mode 100644 index 0000000..572cf3d --- /dev/null +++ b/quest/spec/controllers/api/v1/orders/outbox_items_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +describe Api::V1::Orders::OutboxItemsController do + let_it_be(:order) { create(:order) } + let_it_be(:item_1) { create(:order_outbox_item, :with_payload, order: order) } + let_it_be(:item_2) { create(:order_outbox_item, :with_payload, order: order) } + + describe "#index" do + let(:data) { response.parsed_body.fetch("data") } + + it "returns order outbox items" do + get :index, params: {order_id: order.uuid} + + expect(response).to be_successful + expect(data.size).to eq 2 + expect(data.first.fetch("id")).to eq item_2.uuid + expect(data.second.fetch("id")).to eq item_1.uuid + end + end +end diff --git a/quest/spec/controllers/api/v1/orders_controller_spec.rb b/quest/spec/controllers/api/v1/orders_controller_spec.rb new file mode 100644 index 0000000..01c45e8 --- /dev/null +++ b/quest/spec/controllers/api/v1/orders_controller_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe Api::V1::OrdersController do + describe "#show" do + let(:order) { create(:order) } + + it "returns order" do + expect(get(:show, params: {id: order.uuid})).to be_successful + expect(response.parsed_body["data"]["id"]).to eq order.uuid + end + end + + describe "#create" do + let(:order_params) { {order: {name: "Foo", qty: "1", price: "4.20", description: "Bar"}} } + + context "when params are valid" do + it "returns order" do + expect { post :create, params: order_params }.to change(Order, :count) + expect(response).to be_successful + end + end + + context "when params are invalid" do + it "returns error" do + order_params[:order][:name] = nil + expect { post :create, params: order_params }.not_to change(Order, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + end +end diff --git a/quest/spec/controllers/welcome_controller_spec.rb b/quest/spec/controllers/welcome_controller_spec.rb new file mode 100644 index 0000000..056391d --- /dev/null +++ b/quest/spec/controllers/welcome_controller_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe WelcomeController do + describe "GET index" do + it do + get :index + expect(response).to have_http_status(:success) + end + end +end diff --git a/quest/spec/encoders/order_encoder_spec.rb b/quest/spec/encoders/order_encoder_spec.rb new file mode 100644 index 0000000..1ece641 --- /dev/null +++ b/quest/spec/encoders/order_encoder_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +describe OrderEncoder do + let_it_be(:order) { create(:order, price: 4.2) } + let(:encoder) { described_class.new(order) } + let(:result) { encoder.encode } + let(:encoded_data) { Protobuf::OrderData::Order.decode(result) } + + it "encodes to valid payload" do + expect(encoded_data).to have_attributes( + id: order.uuid, + name: order.name, + qty: order.qty, + status: order.status.upcase.to_sym, + description: order.description + ) + + expect(encoded_data.updated_at.seconds).to eq order.updated_at.to_i + expect(encoded_data.created_at.seconds).to eq order.created_at.to_i + expect(encoded_data.price).to have_attributes( + currency_code: "RUB", + units: 4, + nanos: 20 + ) + end +end diff --git a/quest/spec/factories/order_factory.rb b/quest/spec/factories/order_factory.rb new file mode 100644 index 0000000..24a2b49 --- /dev/null +++ b/quest/spec/factories/order_factory.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :order do + name { Faker::Beer.name } + qty { 1 } + price { 4.20 } + description { Faker::Beer.style } + status { :pending } + + trait :completed do + status { :completed } + end + + trait :canceled do + status { :canceled } + end + end +end diff --git a/quest/spec/factories/order_outbox_item_factory.rb b/quest/spec/factories/order_outbox_item_factory.rb new file mode 100644 index 0000000..774ccc2 --- /dev/null +++ b/quest/spec/factories/order_outbox_item_factory.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :order_outbox_item do + order + payload { "test-payload" } + bucket { 0 } + + trait :with_payload do + after(:build) do |item, ev| + next unless ev.order || item.order + item.payload = OrderEncoder.new(ev.order || item.order).encode + end + end + end +end diff --git a/quest/spec/interactors/orders/complete_order_spec.rb b/quest/spec/interactors/orders/complete_order_spec.rb new file mode 100644 index 0000000..a96aafd --- /dev/null +++ b/quest/spec/interactors/orders/complete_order_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe Orders::CompleteOrder do + let_it_be(:order, reload: true) { create(:order) } + + let(:serivce) { described_class.new(order) } + let(:result) { serivce.call } + + context "when success" do + it "completes the order" do + expect { result }.to change { order.reload.status }.from("pending").to("completed") + expect(result).to be_success + end + end + + context "when failure" do + it "doesn't complete the order" do + order.name = nil + expect(result).to be_failure + expect(order.reload).not_to be_completed + end + end +end diff --git a/quest/spec/interactors/orders/create_order_spec.rb b/quest/spec/interactors/orders/create_order_spec.rb new file mode 100644 index 0000000..d84545c --- /dev/null +++ b/quest/spec/interactors/orders/create_order_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +describe Orders::CreateOrder do + let(:serivce) { described_class.new(params: order_params) } + let(:result) { serivce.call } + let(:order_params) { {name: "Foo", qty: "1", price: "4.20", description: "Bar"} } + + context "when all params are valid" do + it "creates order" do + expect { result }.to change(Order, :count) + expect(result).to be_success + end + end + + context "when params are invalid" do + it "returns error" do + order_params[:name] = nil + expect { result }.not_to change(Order, :count) + expect(result).to be_failure + end + end +end diff --git a/quest/spec/interactors/orders/invalid_complete_order_spec.rb b/quest/spec/interactors/orders/invalid_complete_order_spec.rb new file mode 100644 index 0000000..1f1e6af --- /dev/null +++ b/quest/spec/interactors/orders/invalid_complete_order_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +describe Orders::InvalidCompleteOrder do + let_it_be(:order, reload: true) { create(:order) } + + let(:serivce) { described_class.new(order, **params) } + let(:result) { serivce.call } + let(:outbox_item) { order.outbox_items.last } + let(:params) { {} } + + context "when success" do + it "completes the order" do + expect { result }.to change { order.reload.status }.from("pending").to("completed") + expect(result).to be_success + expect(outbox_item.options[:key]).to eq order.uuid + expect(outbox_item.options[:headers]["Idempotency-Key"]).to be_present + end + end + + context "when failure" do + it "doesn't complete the order" do + order.name = nil + expect(result).to be_failure + expect(order.reload).not_to be_completed + end + end + + context "when pass a custom payload" do + let(:params) { {payload: "custom-payload"} } + + it "stores custom payload to db" do + expect { result }.to change { order.reload.status }.from("pending").to("completed") + expect(result).to be_success + expect(outbox_item.payload).to eq "custom-payload" + end + end + + context "when pass an empty_message_key" do + let(:params) { {empty_message_key: true} } + + it "doesn't store message key to options" do + expect { result }.to change { order.reload.status }.from("pending").to("completed") + expect(result).to be_success + expect(outbox_item.options.key?(:key)).to be false + end + end + + context "when pass an empty_idempotency_key" do + let(:params) { {empty_idempotency_key: true} } + + it "doesn't store idempotency key to headers" do + expect { result }.to change { order.reload.status }.from("pending").to("completed") + expect(result).to be_success + expect(outbox_item.options[:headers]["Idempotency-Key"]).to be_empty + end + end +end diff --git a/quest/spec/models/order_outbox_item_spec.rb b/quest/spec/models/order_outbox_item_spec.rb new file mode 100644 index 0000000..904e281 --- /dev/null +++ b/quest/spec/models/order_outbox_item_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +describe OrderOutboxItem do + it "creates an outbox item" do + expect(create(:order_outbox_item, :with_payload)).to be_persisted + end +end diff --git a/quest/spec/models/order_spec.rb b/quest/spec/models/order_spec.rb new file mode 100644 index 0000000..311f638 --- /dev/null +++ b/quest/spec/models/order_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Order do + let(:order) { build(:order) } + + it "creates an order" do + expect(order.save).to be true + end +end diff --git a/quest/spec/rails_helper.rb b/quest/spec/rails_helper.rb new file mode 100644 index 0000000..2fbc93a --- /dev/null +++ b/quest/spec/rails_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] = "test" + +require "bundler/setup" + +begin + require File.expand_path("../../config/environment", __FILE__) +rescue => e + # Fail fast if application couldn't be loaded + $stdout.puts "Failed to load the app: #{e.message}\n#{e.backtrace.take(5).join("\n")}" + exit(1) +end + +require "rspec/rails" +require "faker" +require "test_prof/recipes/rspec/before_all" +require "test_prof/recipes/rspec/let_it_be" + +Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } + +Faker::Config.random = Random.new(0xCAFEBABE) + +RSpec.configure do |config| + # Add `travel_to` + config.include ActiveSupport::Testing::TimeHelpers + # Add `fixture_file_upload` + config.include ActionDispatch::TestProcess::FixtureFile + config.include Shoulda::Matchers::ActiveModel + config.include FactoryBot::Syntax::Methods + + config.fixture_path = Rails.root.join("spec/fixtures") + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! + + config.after do + Rails.cache.clear + end +end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/quest/spec/serializers/order_outbox_item_serializer_spec.rb b/quest/spec/serializers/order_outbox_item_serializer_spec.rb new file mode 100644 index 0000000..63eb563 --- /dev/null +++ b/quest/spec/serializers/order_outbox_item_serializer_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +describe OrderOutboxItemSerializer do + let_it_be(:outbox_item) { create(:order_outbox_item, :with_payload) } + + let(:order) { outbox_item.order } + let(:serializer) { described_class.new(outbox_item) } + let(:result) { serializer.serializable_hash } + let(:expected_attribute_names) do + [ + :event_key, + :bucket, + :status, + :options, + :errors_count, + :error_log, + :payload, + :processed_at, + :created_at, + :updated_at + ] + end + + it "serializes required attributes" do + expect(result[:data][:attributes]).to include(*expected_attribute_names) + expect(result[:data][:attributes][:payload]).to include(:id, :name, :qty, :price, :description) + end +end diff --git a/quest/spec/serializers/order_serializer_spec.rb b/quest/spec/serializers/order_serializer_spec.rb new file mode 100644 index 0000000..2b122ea --- /dev/null +++ b/quest/spec/serializers/order_serializer_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe OrderSerializer do + let(:serializer) { described_class.new(order) } + let(:result) { serializer.serializable_hash } + let(:expected_attribute_names) do + [ + :name, + :qty, + :status, + :price, + :description, + :updated_at, + :created_at + ] + end + + let_it_be(:order) { create(:order) } + + it "serializes required attributes" do + expect(result[:data][:attributes]).to include(*expected_attribute_names) + end +end diff --git a/quest/spec/serializers/validation_errors_serializer_spec.rb b/quest/spec/serializers/validation_errors_serializer_spec.rb new file mode 100644 index 0000000..9a18f19 --- /dev/null +++ b/quest/spec/serializers/validation_errors_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +describe ValidationErrorsSerializer do + let(:serializer) { described_class.new(errors, is_collection: false) } + let(:errors) { object.errors } + let(:result) { serializer.serializable_hash } + + describe "#full_messages" do + let(:object) { build(:order, name: nil, qty: nil) } + + it "renders" do + expect(object).not_to be_valid + expect(result[:data][:attributes][:full_messages]) + .to include("Name can't be blank", "Qty can't be blank") + end + end + + describe "#details" do + let(:object) { build(:order, name: nil, qty: nil) } + + it "renders" do + expect(object).not_to be_valid + expect(result[:data][:attributes][:details]) + .to eq(name: [{error: :blank}], qty: [{error: :blank}]) + end + end +end diff --git a/quest/spec/spec_helper.rb b/quest/spec/spec_helper.rb new file mode 100644 index 0000000..7452182 --- /dev/null +++ b/quest/spec/spec_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rspec" +require "rspec_junit_formatter" +require "rspec/json_expectations" +require "shoulda-matchers" +require "yabeda" +require "yabeda/rspec" + +RSpec::Matchers.define_negated_matcher :not_change, :change +RSpec::Matchers.define_negated_matcher :not_include, :include + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = ".rspec_status" + config.run_all_when_everything_filtered = true + + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + config.order = :random + Kernel.srand config.seed +end diff --git a/quest/tmp/.keep b/quest/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/voyage/.dockerdev/.psqlrc b/voyage/.dockerdev/.psqlrc new file mode 100644 index 0000000..7b8dc9e --- /dev/null +++ b/voyage/.dockerdev/.psqlrc @@ -0,0 +1,23 @@ +-- Don't display the "helpful" message on startup. +\set QUIET 1 + +-- psql writes to a temporary file before then moving that temporary file on top of the old history file +-- a bind mount of a file only bind mounts the inode, so a rename like this won't ever work +\set HISTFILE /var/log/psql_history/.psql_history + +-- Use best available output format +\x auto + +-- Verbose error reports +\set VERBOSITY verbose + +-- If a command is run more than once in a row, +-- only store it once in the history +\set HISTCONTROL ignoredups +\set COMP_KEYWORD_CASE upper + +-- By default, NULL displays as an empty space. Is it actually an empty +-- string, or is it null? This makes that distinction visible +\pset null '[NULL]' + +\unset QUIET diff --git a/voyage/.dockerdev/Dockerfile b/voyage/.dockerdev/Dockerfile new file mode 100644 index 0000000..296db59 --- /dev/null +++ b/voyage/.dockerdev/Dockerfile @@ -0,0 +1,55 @@ +ARG BASE_IMAGE + +FROM ${BASE_IMAGE} + +ARG POSTGRES_VERSION +ARG RUBYGEMS_VERSION +ARG BUNDLER_VERSION + +ENV RAILS_ENV=development + +# Common dependencies +RUN apt-get update -qq \ + && apt-get dist-upgrade -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + build-essential \ + gnupg2 \ + pkg-config \ + ca-certificates \ + curl \ + wget \ + less \ + git \ + vim \ + shared-mime-info \ + && apt-get clean \ + && rm -rf /var/cache/apt/archives/* \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + + +# Postgres client +RUN curl -sSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgres-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/postgres-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt/" bookworm-pgdg main $POSTGRES_VERSION | tee /etc/apt/sources.list.d/postgres.list > /dev/null + +# App's dependencies +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + postgresql-client-${POSTGRES_VERSION} \ + libpq-dev \ + file \ + && apt-get clean \ + && rm -rf /var/cache/apt/archives/* \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && truncate -s 0 /var/log/*log + +# Bundler +ENV LANG=C.UTF-8 \ + BUNDLE_JOBS=4 \ + BUNDLE_RETRY=3 +RUN gem update --system ${RUBYGEMS_VERSION} \ + && gem install --default bundler -v ${BUNDLER_VERSION} + +EXPOSE 3000 + +CMD ["/usr/bin/bash"] diff --git a/voyage/.dockerdev/docker-compose.yml b/voyage/.dockerdev/docker-compose.yml new file mode 100644 index 0000000..1894751 --- /dev/null +++ b/voyage/.dockerdev/docker-compose.yml @@ -0,0 +1,118 @@ +x-environments: &environments + HISTFILE: /app/log/.bash_history + EDITOR: vim + prometheus_multiproc_dir: ./tmp + BUNDLE_APP_CONFIG: ../.bundle + DATABASE_URL: postgres://postgres:keepinsecret@postgres:5432 + REDIS_URL: redis://redis:6379 + KAFKA_BROKERS: kafka.infranet:9092 + +x-ruby: &ruby + build: + context: . + dockerfile: ./Dockerfile + args: + # Keep in sync with Gitlab CI configs + BASE_IMAGE: ruby:3.3-bookworm + POSTGRES_VERSION: '16' + RUBYGEMS_VERSION: '3.5.6' + BUNDLER_VERSION: '2.5.5' + image: quest-dev:1.0.0 + environment: + <<: *environments + tmpfs: + - /tmp + stdin_open: true + tty: true + working_dir: ${WORK_DIR:-/app} + volumes: + - ../..:/gems:cached + - ..:/app:cached + - .psqlrc:/root/.psqlrc:ro + # We store Rails cache and gems in volumes to get speed up on Docker for Mac + - rails_cache:/app/tmp/cache + - bundle:/usr/local/bundle + networks: + - default + - infranet + +x-rails-deps: &rails-deps + postgres: + condition: service_healthy + redis: + condition: service_healthy + +x-rails: &rails + <<: *ruby + depends_on: + <<: *rails-deps + +name: voyage + +services: + backend: + <<: *rails + command: /bin/bash + profiles: + - donotstart + + puma: + <<: *rails + command: bundle exec puma + ports: + - '3001:3000' + + sidekiq: + <<: *rails + command: bundle exec sidekiq -C config/sidekiq.yml + + schked: + <<: *rails + command: bundle exec schked start + + kafka-consumer: + <<: *rails + depends_on: + <<: *rails-deps + command: bundle exec kafka_consumer + + outbox: + <<: *rails + command: bundle exec outbox start + + postgres: + image: postgres:16-bookworm + volumes: + - postgres:/var/lib/postgresql/data + - .psqlrc:/root/.psqlrc:ro + - ../log:/var/log/psql_history + ports: + - 5432 + environment: + POSTGRES_PASSWORD: keepinsecret + healthcheck: + test: pg_isready -U postgres -h 127.0.0.1 + interval: 10s + + redis: + image: redis:7-bookworm + environment: + ALLOW_EMPTY_PASSWORD: "yes" + volumes: + - redis:/data + ports: + - 6379 + healthcheck: + test: redis-cli ping + interval: 10s + +volumes: + bundle: + postgres: + redis: + rails_cache: + +networks: + infranet: + name: infranet + external: true diff --git a/voyage/.dockerignore b/voyage/.dockerignore new file mode 100644 index 0000000..c7423a3 --- /dev/null +++ b/voyage/.dockerignore @@ -0,0 +1,24 @@ +# add git-ignore syntax here of things you don't want copied into docker image + +# for all code you usually don't want .git history in image, just the current commit you have checked out +.git + +# you usually don't want dockerfile and compose files in the image either +*Dockerfile* +*docker-compose* + +vendor/bundle/* +**/node_modules/* +**/log/* +**/tmp/* +!/tmp/cache/webpacker +/.idea/ +/public/assets/ +/public/system/ +/public/storage/ +/public/uploads/ +/config/master.key +/config/credentials/*.key + +# Exclude Helm chart to avoid image rebuild after deploy-related changes +/deployment/ diff --git a/voyage/.env b/voyage/.env new file mode 100644 index 0000000..de941f6 --- /dev/null +++ b/voyage/.env @@ -0,0 +1,6 @@ +MALLOC_ARENA_MAX=2 +RAILS_ENV=development +SENTRY_DSN= +AUTHORIZATION_SERVICE_HOST= +HEALTH_CHECK_PORT=8048 +PROMETHEUS_EXPORTER_PORT=9090 diff --git a/voyage/.env.development b/voyage/.env.development new file mode 100644 index 0000000..c8a84d6 --- /dev/null +++ b/voyage/.env.development @@ -0,0 +1,3 @@ +EAGER_LOAD=no +CODE_RELOAD=yes +CACHE=no diff --git a/voyage/.env.test b/voyage/.env.test new file mode 100644 index 0000000..9333b81 --- /dev/null +++ b/voyage/.env.test @@ -0,0 +1,3 @@ +EAGER_LOAD=no +CODE_RELOAD=no +CACHE=no diff --git a/voyage/.gitignore b/voyage/.gitignore new file mode 100644 index 0000000..03a0e2b --- /dev/null +++ b/voyage/.gitignore @@ -0,0 +1,68 @@ +# See https://help.github.com/articles/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. +vendor/bundle + +# Ignore IDEs +/.idea +/.direnv +.envrc + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +!/tmp/*/.keep + +# Ignore uploaded files in development. +/public/storage + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key +/public/packs +/public/packs-test +/public/manage-by-packs +/node_modules +/yarn-error.log +yarn-debug.log* +.yarn-integrity + +/.env.local +/.env.development.local +/.env.test.local +*.local.yml + +.lefthook-local/ +lefthook-local.yml + +/config/credentials/*.key +/config/credentials/local.* + +.dockerdev/docker-compose.local.yml +dip.override.yml + +# helm chart files with secrets +/deployment/**/values/*.yml +/deployment/**/values/*.yaml + +/config/credentials/local.key + +# Results of load testing +/spec/load/results/ + +# Rspec coverage +/coverage/ + +.ruby-gemset +.ruby-version +.rspec_status + +api/grpc/health.proto +.DS_Store diff --git a/voyage/.irbrc b/voyage/.irbrc new file mode 100644 index 0000000..53772bb --- /dev/null +++ b/voyage/.irbrc @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +IRB.conf[:SAVE_HISTORY] = 1000 +IRB.conf[:HISTORY_FILE] = "#{__dir__}/log/.irb_history" +IRB.conf[:USE_AUTOCOMPLETE] = false + +ENV["XDG_DATA_HOME"] ||= "#{__dir__}/tmp" diff --git a/voyage/.rspec b/voyage/.rspec new file mode 100644 index 0000000..a472683 --- /dev/null +++ b/voyage/.rspec @@ -0,0 +1,3 @@ +--color +--require spec_helper +--require rails_helper diff --git a/voyage/.rubocop.yml b/voyage/.rubocop.yml new file mode 100644 index 0000000..0e1a0b5 --- /dev/null +++ b/voyage/.rubocop.yml @@ -0,0 +1,91 @@ +# We want Exclude directives from different +# config files to get merged, not overwritten +inherit_mode: + merge: + - Exclude + +inherit_gem: + standard: config/base.yml + +AllCops: + TargetRubyVersion: 3.3 + TargetRailsVersion: 7.1 + NewCops: enable + SuggestExtensions: false + +require: + - standard + - rubocop-performance + - rubocop-rails + - rubocop-rspec + +# ===== Style + +Style/SingleLineMethods: + Enabled: false + +Style/EmptyMethod: + Enabled: false + +# ===== Rails + +Rails/NotNullColumn: + Enabled: false + +Rails/UniqueValidationWithoutIndex: + Enabled: false + +Rails/SkipsModelValidations: + Enabled: false + +Rails/HasManyOrHasOneDependent: + Enabled: false + +Rails/CreateTableWithTimestamps: + Enabled: false + +Rails/UnknownEnv: + Environments: + - development + - test + - staging + - production + +Rails/RakeEnvironment: + Enabled: false + +# ===== Rspec + +RSpec/AnyInstance: + Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/LetSetup: + Enabled: false + +RSpec/StubbedMock: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/EmptyExampleGroup: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Enabled: false + +RSpec/VariableName: + Enabled: false + +RSpec/FilePath: + Exclude: + - "**/cops/**/*_spec.rb" diff --git a/voyage/.schked b/voyage/.schked new file mode 100644 index 0000000..4328ac4 --- /dev/null +++ b/voyage/.schked @@ -0,0 +1 @@ +--require Schkedfile diff --git a/voyage/CODEOWNERS b/voyage/CODEOWNERS new file mode 100644 index 0000000..e69de29 diff --git a/voyage/Gemfile b/voyage/Gemfile new file mode 100644 index 0000000..bef128a --- /dev/null +++ b/voyage/Gemfile @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +source "https://rubygems.org/" + +ruby "~> 3.3" + +gem "puma" +gem "rails", "~> 7.1" +gem "rails-i18n" +gem "pg" +gem "redis" +gem "anyway_config" +gem "jsonapi-serializer", "~> 2.0" +gem "dry-initializer" +gem "grpc" +gem "google-protobuf" +gem "nanoid", "~> 2.0" +gem "sentry-rails" +gem "sentry-ruby" +gem "sentry-sidekiq" +gem "schked" +gem "yabeda-activerecord" +gem "yabeda-prometheus-mmap" +gem "yabeda-puma-plugin" +gem "yabeda-rails" +gem "yabeda-sidekiq" +gem "yabeda-schked" +gem "yabeda-http_requests" +gem "http_health_check" +gem "strong_migrations" +gem "rails_semantic_logger" +gem "pagy", "~> 6.0" + +# Sbermarket Tech gems +gem "sbmt-outbox" +gem "sbmt-kafka_consumer" + +group :development, :test do + gem "rspec" + gem "rspec_junit_formatter" + gem "rspec-rails" + gem "rspec-json_expectations" + gem "shoulda-matchers" + gem "dotenv-rails" + gem "bundler-audit" + gem "listen" + gem "factory_bot_rails" + gem "faker" +end + +group :development do + gem "rubocop-rails" + gem "rubocop-rspec" + gem "standard" + gem "grpc-tools" +end + +group :test do + gem "test-prof" +end diff --git a/voyage/Gemfile.lock b/voyage/Gemfile.lock new file mode 100644 index 0000000..bffd136 --- /dev/null +++ b/voyage/Gemfile.lock @@ -0,0 +1,541 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + mail (>= 2.7.1) + net-imap + net-pop + net-smtp + actionmailer (7.1.3.2) + actionpack (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activesupport (= 7.1.3.2) + mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.2) + actionpack (7.1.3.2) + actionview (= 7.1.3.2) + activesupport (= 7.1.3.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.3.2) + actionpack (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.1.3.2) + activesupport (= 7.1.3.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.3.2) + activesupport (= 7.1.3.2) + globalid (>= 0.3.6) + activemodel (7.1.3.2) + activesupport (= 7.1.3.2) + activerecord (7.1.3.2) + activemodel (= 7.1.3.2) + activesupport (= 7.1.3.2) + timeout (>= 0.4.0) + activestorage (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activesupport (= 7.1.3.2) + marcel (~> 1.0) + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + anyway_config (2.6.3) + ruby-next-core (~> 1.0) + ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.6) + builder (3.2.4) + bundler-audit (0.9.1) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + crass (1.0.6) + cutoff (0.5.2) + date (3.3.4) + diff-lcs (1.5.1) + dotenv (3.1.0) + dotenv-rails (3.1.0) + dotenv (= 3.1.0) + railties (>= 6.1) + drb (2.2.0) + ruby2_keywords + dry-core (1.0.1) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) + dry-inflector (1.0.0) + dry-initializer (3.1.1) + dry-logic (1.5.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-monads (1.6.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-struct (1.6.0) + dry-core (~> 1.0, < 2) + dry-types (>= 1.7, < 2) + ice_nine (~> 0.11) + zeitwerk (~> 2.6) + dry-types (1.7.2) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + erubi (1.12.0) + et-orbi (1.2.7) + tzinfo + exponential-backoff (0.0.4) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) + faker (3.2.3) + i18n (>= 1.8.11, < 2) + ffi (1.16.3) + fugit (1.10.1) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) + globalid (1.2.1) + activesupport (>= 6.1) + google-protobuf (3.25.3) + google-protobuf (3.25.3-aarch64-linux) + google-protobuf (3.25.3-arm64-darwin) + google-protobuf (3.25.3-x86-linux) + google-protobuf (3.25.3-x86_64-darwin) + google-protobuf (3.25.3-x86_64-linux) + googleapis-common-protos-types (1.13.0) + google-protobuf (~> 3.18) + grpc (1.62.0) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-aarch64-linux) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-arm64-darwin) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-x86-linux) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-x86_64-darwin) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.62.0-x86_64-linux) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc-tools (1.62.0) + http_health_check (0.5.0) + rack (~> 2.0) + webrick + i18n (1.14.1) + concurrent-ruby (~> 1.0) + ice_nine (0.11.2) + io-console (0.7.2) + irb (1.11.2) + rdoc + reline (>= 0.4.2) + json (2.7.1) + jsonapi-serializer (2.2.0) + activesupport (>= 4.2) + karafka (2.3.3) + karafka-core (>= 2.3.0, < 2.4.0) + waterdrop (>= 2.6.12, < 3.0.0) + zeitwerk (~> 2.3) + karafka-core (2.3.0) + karafka-rdkafka (>= 0.14.8, < 0.15.0) + karafka-rdkafka (0.14.10) + ffi (~> 1.15) + mini_portile2 (~> 2.6) + rake (> 12) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + loofah (2.22.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.3) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + minitest (5.22.2) + mutex_m (0.2.0) + nanoid (2.0.0) + net-imap (0.4.10) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.4.0.1) + net-protocol + nio4r (2.7.0) + nokogiri (1.16.2) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.16.2-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.2-arm-linux) + racc (~> 1.4) + nokogiri (1.16.2-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.2-x86-linux) + racc (~> 1.4) + nokogiri (1.16.2-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.2-x86_64-linux) + racc (~> 1.4) + pagy (6.5.0) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + pg (1.5.5) + prism (0.24.0) + prometheus-client-mmap (1.1.1) + rb_sys (~> 0.9.86) + prometheus-client-mmap (1.1.1-aarch64-linux) + rb_sys (~> 0.9.86) + prometheus-client-mmap (1.1.1-arm64-darwin) + rb_sys (~> 0.9.86) + prometheus-client-mmap (1.1.1-x86_64-darwin) + rb_sys (~> 0.9.86) + prometheus-client-mmap (1.1.1-x86_64-linux) + rb_sys (~> 0.9.86) + psych (5.1.2) + stringio + puma (6.4.2) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.7.3) + rack (2.2.8.1) + rack-session (1.0.2) + rack (< 3) + rack-test (2.1.0) + rack (>= 1.3) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.3.2) + actioncable (= 7.1.3.2) + actionmailbox (= 7.1.3.2) + actionmailer (= 7.1.3.2) + actionpack (= 7.1.3.2) + actiontext (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activemodel (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + bundler (>= 1.15.0) + railties (= 7.1.3.2) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails-i18n (7.0.8) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) + rails_semantic_logger (4.14.0) + rack + railties (>= 5.1) + semantic_logger (~> 4.13) + railties (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.1.0) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + rb_sys (0.9.89) + rdoc (6.6.2) + psych (>= 4.0.0) + redis (5.1.0) + redis-client (>= 0.17.0) + redis-client (0.20.0) + connection_pool + redlock (2.0.6) + redis-client (>= 0.14.1, < 1.0.0) + regexp_parser (2.9.0) + reline (0.4.3) + io-console (~> 0.5) + rexml (3.2.6) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-json_expectations (2.2.0) + rspec-mocks (3.13.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.1) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + rspec-support (3.13.1) + rspec_junit_formatter (0.6.0) + rspec-core (>= 2, < 4, != 2.12.0) + rubocop (1.61.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.0) + parser (>= 3.3.0.4) + prism (>= 0.24.0) + rubocop-capybara (2.20.0) + rubocop (~> 1.41) + rubocop-factory_bot (2.25.1) + rubocop (~> 1.41) + rubocop-performance (1.20.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rails (2.23.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rspec (2.26.1) + rubocop (~> 1.40) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) + ruby-next-core (1.0.2) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + rufus-scheduler (3.9.1) + fugit (~> 1.1, >= 1.1.6) + sbmt-kafka_consumer (2.0.0) + anyway_config (>= 2.4.0) + dry-struct + karafka (~> 2.2) + rails (>= 5.2) + thor + yabeda (>= 0.11) + zeitwerk (~> 2.3) + sbmt-outbox (5.0.1) + connection_pool (~> 2.0) + cutoff (~> 0.5) + dry-initializer (~> 3.0) + dry-monads (~> 1.3) + exponential-backoff (~> 0.0) + http_health_check (~> 0.5) + rails (>= 5.2, < 8) + redis-client (>= 0.14.1, < 1.0.0) + redlock (> 1.0, < 3.0) + thor (>= 0.20, < 2) + yabeda (~> 0.8) + schked (1.3.0) + connection_pool (~> 2.0) + redlock (> 1.0, < 3.0) + rufus-scheduler (~> 3.0) + thor + semantic_logger (4.15.0) + concurrent-ruby (~> 1.0) + sentry-rails (5.16.1) + railties (>= 5.0) + sentry-ruby (~> 5.16.1) + sentry-ruby (5.16.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + sentry-sidekiq (5.16.1) + sentry-ruby (~> 5.16.1) + sidekiq (>= 3.0) + shoulda-matchers (6.1.0) + activesupport (>= 5.2.0) + sidekiq (7.2.2) + concurrent-ruby (< 2) + connection_pool (>= 2.3.0) + rack (>= 2.2.4) + redis-client (>= 0.19.0) + sniffer (0.5.0) + anyway_config (>= 1.0) + dry-initializer (~> 3) + standard (1.34.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.60) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.3.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.20.2) + stringio (3.1.0) + strong_migrations (1.7.0) + activerecord (>= 5.2) + test-prof (1.3.1) + thor (1.3.1) + timeout (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + waterdrop (2.6.14) + karafka-core (>= 2.2.3, < 3.0.0) + zeitwerk (~> 2.3) + webrick (1.8.1) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + yabeda (0.12.0) + anyway_config (>= 1.0, < 3) + concurrent-ruby + dry-initializer + yabeda-activerecord (0.1.1) + activerecord (>= 6.0) + yabeda (~> 0.6) + yabeda-http_requests (0.2.1) + sniffer + yabeda + yabeda-prometheus-mmap (0.4.0) + prometheus-client-mmap + yabeda (~> 0.10) + yabeda-puma-plugin (0.7.1) + json + puma + yabeda (~> 0.5) + yabeda-rails (0.9.0) + activesupport + anyway_config (>= 1.3, < 3) + railties + yabeda (~> 0.8) + yabeda-schked (0.2.0) + schked (>= 0.3, < 2) + yabeda (~> 0.8) + yabeda-sidekiq (0.11.0) + anyway_config (>= 1.3, < 3) + sidekiq + yabeda (~> 0.6) + zeitwerk (2.6.13) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + ruby + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + anyway_config + bundler-audit + dotenv-rails + dry-initializer + factory_bot_rails + faker + google-protobuf + grpc + grpc-tools + http_health_check + jsonapi-serializer (~> 2.0) + listen + nanoid (~> 2.0) + pagy (~> 6.0) + pg + puma + rails (~> 7.1) + rails-i18n + rails_semantic_logger + redis + rspec + rspec-json_expectations + rspec-rails + rspec_junit_formatter + rubocop-rails + rubocop-rspec + sbmt-kafka_consumer + sbmt-outbox + schked + sentry-rails + sentry-ruby + sentry-sidekiq + shoulda-matchers + standard + strong_migrations + test-prof + yabeda-activerecord + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails + yabeda-schked + yabeda-sidekiq + +RUBY VERSION + ruby 3.3.0p0 + +BUNDLED WITH + 2.5.6 diff --git a/voyage/Kafkafile b/voyage/Kafkafile new file mode 100644 index 0000000..3776579 --- /dev/null +++ b/voyage/Kafkafile @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'config/environment' diff --git a/voyage/Outboxfile b/voyage/Outboxfile new file mode 100644 index 0000000..87a8e4f --- /dev/null +++ b/voyage/Outboxfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +Yabeda::Prometheus::Exporter.start_metrics_server! diff --git a/voyage/README.md b/voyage/README.md new file mode 100644 index 0000000..c4efe51 --- /dev/null +++ b/voyage/README.md @@ -0,0 +1,28 @@ +# Voyage + +This is an example application which uses: +- [sbmt-outbox](https://github.com/SberMarket-Tech/sbmt-outbox) +- [sbmt-kafka_consumer](https://github.com/SberMarket-Tech/sbmt-kafka_consumer) + +It allows you to learn how to use the Outbox pattern and how it works with Ruby on Rails. + +## Development + +1. Install deps and prepare DB + +```shell +dip provision +``` + +2. Run Puma server + +```shell +dip rails s +``` + +3. Run tests + +```shell +dip rake db:create db:migrate RAILS_ENV=test +dip rspec +``` diff --git a/voyage/Rakefile b/voyage/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/voyage/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/voyage/Schkedfile b/voyage/Schkedfile new file mode 100644 index 0000000..7ad716f --- /dev/null +++ b/voyage/Schkedfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "config/environment" + +Rails.application.load_tasks + +Yabeda::Prometheus::Exporter.start_metrics_server! if defined?(Yabeda) + +::HttpHealthCheck.run_server_async(port: ENV.fetch("HEALTH_CHECK_PORT").to_i) diff --git a/voyage/api/google/type/money.proto b/voyage/api/google/type/money.proto new file mode 100644 index 0000000..ef41f10 --- /dev/null +++ b/voyage/api/google/type/money.proto @@ -0,0 +1,43 @@ +// Copyright 2019 Google LLC. +// +// 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 +// +// http://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. +// + +syntax = "proto3"; + +package google.type; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/type/money;money"; +option java_multiple_files = true; +option java_outer_classname = "MoneyProto"; +option java_package = "com.google.type"; +option objc_class_prefix = "GTP"; + +// Represents an amount of money with its currency type. +message Money { + // The 3-letter currency code defined in ISO 4217. + string currency_code = 1; + + // The whole units of the amount. + // For example if `currencyCode` is `"USD"`, then 1 unit is one US dollar. + int64 units = 2; + + // Number of nano (10^-9) units of the amount. + // The value must be between -999,999,999 and +999,999,999 inclusive. + // If `units` is positive, `nanos` must be positive or zero. + // If `units` is zero, `nanos` can be positive, zero, or negative. + // If `units` is negative, `nanos` must be negative or zero. + // For example $-1.75 is represented as `units`=-1 and `nanos`=-750,000,000. + int32 nanos = 3; +} diff --git a/voyage/app/controllers/api/v1/orders/inbox_items_controller.rb b/voyage/app/controllers/api/v1/orders/inbox_items_controller.rb new file mode 100644 index 0000000..a5e53eb --- /dev/null +++ b/voyage/app/controllers/api/v1/orders/inbox_items_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module V1 + module Orders + class InboxItemsController < ApplicationController + def index + order = Order.find_by!(uuid: params.require(:order_id)) + scope = order.inbox_items.order(id: :desc) + @pagy, inbox_items = pagy(scope) + render_list(inbox_items, OrderInboxItemSerializer) + end + end + end + end +end diff --git a/voyage/app/controllers/api/v1/orders/processings_controller.rb b/voyage/app/controllers/api/v1/orders/processings_controller.rb new file mode 100644 index 0000000..3b7f115 --- /dev/null +++ b/voyage/app/controllers/api/v1/orders/processings_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Api + module V1 + module Orders + class ProcessingsController < ApplicationController + def create + order = Order.find_by!(uuid: params.require(:order_id)) + render_result ::Orders::ProcessOrder.call(order) + end + end + end + end +end diff --git a/voyage/app/controllers/api/v1/orders_controller.rb b/voyage/app/controllers/api/v1/orders_controller.rb new file mode 100644 index 0000000..55544c5 --- /dev/null +++ b/voyage/app/controllers/api/v1/orders_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module V1 + class OrdersController < ApplicationController + def index + @pagy, orders = pagy(Order.order(id: :desc)) + render_list(orders, OrderSerializer) + end + + def show + render_object Order.find_by!(uuid: params.require(:id)) + end + end + end +end diff --git a/voyage/app/controllers/application_controller.rb b/voyage/app/controllers/application_controller.rb new file mode 100644 index 0000000..96dd1ac --- /dev/null +++ b/voyage/app/controllers/application_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::API + include Pagy::Backend + + after_action { pagy_headers_merge(@pagy) if @pagy } + + private + + def render_result(result, serializer_class = nil) + if result.success? + render_object(result.success, serializer_class) + else + render_validation_errors(result.failure) + end + end + + def render_object(object, serializer_class = nil) + serializer_class ||= lookup_serializer(object) + serializer = serializer_class.new(object) + render json: serializer.serializable_hash + end + + def render_list(collection, serializer_class) + serializer = serializer_class.new(collection) + render json: serializer.serializable_hash + end + + def render_validation_errors(errors) + serializer = ValidationErrorsSerializer.new(errors, is_collection: false) + render json: serializer.serializable_hash, status: :unprocessable_entity + end + + def lookup_serializer(object) + "#{object.class.name}Serializer".constantize + end +end diff --git a/voyage/app/controllers/welcome_controller.rb b/voyage/app/controllers/welcome_controller.rb new file mode 100644 index 0000000..4769cc9 --- /dev/null +++ b/voyage/app/controllers/welcome_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class WelcomeController < ApplicationController + def index + render json: { + it: :works, + ruby: RUBY_VERSION.to_s, + rails: Rails.version.to_s + } + end +end diff --git a/voyage/app/decoders/application_decoder.rb b/voyage/app/decoders/application_decoder.rb new file mode 100644 index 0000000..62dbac2 --- /dev/null +++ b/voyage/app/decoders/application_decoder.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ApplicationDecoder + extend Dry::Initializer + + param :data + option :message_klass + + class << self + def decode(...) + new(...).decode + end + end + + def decode + message_klass.decode(data).to_h + end + + private + + def decode_money(value) + return unless value&.units + BigDecimal("#{value.units}.#{value.nanos}") + end + + def decode_time(value) + return unless value&.seconds + Time.zone.at(value.seconds, value.nanos, :nsec) + end +end diff --git a/voyage/app/decoders/order_decoder.rb b/voyage/app/decoders/order_decoder.rb new file mode 100644 index 0000000..878b9a6 --- /dev/null +++ b/voyage/app/decoders/order_decoder.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../../pkg/client/quest/events/order_pb" + +class OrderDecoder < ApplicationDecoder + option :message_klass, default: -> { Protobuf::OrderData::Order } + + def decode + payload = message_klass.decode(data) + + { + id: payload.id, + name: payload.name, + qty: payload.qty, + description: payload.description, + status: payload.status.downcase, + price: decode_money(payload.price), + updated_at: decode_time(payload.updated_at), + created_at: decode_time(payload.created_at) + } + end +end diff --git a/voyage/app/interactors/application_interactor.rb b/voyage/app/interactors/application_interactor.rb new file mode 100644 index 0000000..aa9e924 --- /dev/null +++ b/voyage/app/interactors/application_interactor.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ApplicationInteractor + extend Dry::Initializer + include Dry::Monads[:result, :do] + + def self.call(...) + new(...).call + end +end diff --git a/voyage/app/interactors/inbox_importers/base_importer.rb b/voyage/app/interactors/inbox_importers/base_importer.rb new file mode 100644 index 0000000..c58c7d6 --- /dev/null +++ b/voyage/app/interactors/inbox_importers/base_importer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module InboxImporters + class BaseImporter < ApplicationInteractor + param :inbox_item + param :payload + + def call + raise NotImplementedError + end + end +end diff --git a/voyage/app/interactors/inbox_importers/order_importer.rb b/voyage/app/interactors/inbox_importers/order_importer.rb new file mode 100644 index 0000000..20bbee0 --- /dev/null +++ b/voyage/app/interactors/inbox_importers/order_importer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module InboxImporters + class OrderImporter < BaseImporter + def call + decoded_payload = OrderDecoder.decode(payload) + + order = build_order(decoded_payload) + + if order.save + Success(order) + else + Failure(order.errors) + end + end + + private + + def build_order(payload) + uuid = payload.fetch(:id) + order = Order.find_by(uuid: uuid) || Order.new(uuid: uuid) + + order.assign_attributes( + status: payload.fetch(:status), + name: payload.fetch(:name), + qty: payload.fetch(:qty), + description: payload.fetch(:description), + price: payload.fetch(:price) + ) + + order + end + end +end diff --git a/voyage/app/interactors/inbox_importers/outbox_transport_factory.rb b/voyage/app/interactors/inbox_importers/outbox_transport_factory.rb new file mode 100644 index 0000000..05ffa3d --- /dev/null +++ b/voyage/app/interactors/inbox_importers/outbox_transport_factory.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module InboxImporters + class OutboxTransportFactory + def self.build(target:) + "InboxImporters::#{target.camelize}Importer".constantize + end + end +end diff --git a/voyage/app/interactors/orders/process_order.rb b/voyage/app/interactors/orders/process_order.rb new file mode 100644 index 0000000..0692ada --- /dev/null +++ b/voyage/app/interactors/orders/process_order.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Orders + class ProcessOrder < ApplicationInteractor + param :order + + def call + order.processed! + + Success(order) + rescue ActiveRecord::RecordInvalid + Failure(order.errors) + end + end +end diff --git a/voyage/app/lib/error_tracker.rb b/voyage/app/lib/error_tracker.rb new file mode 100644 index 0000000..71b63fc --- /dev/null +++ b/voyage/app/lib/error_tracker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ErrorTracker + LEVELS = %i[info error debug warning fatal].freeze + + class << self + LEVELS.each do |level| + define_method(level) do |message, **args| + notify(message, level: level, **args) + end + end + + def notify(message, level: :error, tags: {}, **args) + Sentry.with_scope do |scope| + scope.set_tags(tags) + + case message + when String + Sentry.capture_message(message, level: level, extra: args) + else + Sentry.capture_exception(message, level: level, extra: args) + end + end + end + end +end diff --git a/voyage/app/models/application_record.rb b/voyage/app/models/application_record.rb new file mode 100644 index 0000000..71fbba5 --- /dev/null +++ b/voyage/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/voyage/app/models/order.rb b/voyage/app/models/order.rb new file mode 100644 index 0000000..594b27a --- /dev/null +++ b/voyage/app/models/order.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Order < ApplicationRecord + enum :status, { + completed: "completed", + processed: "processed" + } + + has_many :inbox_items, class_name: "OrderInboxItem", foreign_key: :event_key, primary_key: :uuid, inverse_of: :order + + validates :name, :qty, :price, presence: true + + after_initialize do + self.uuid ||= Nanoid.generate(size: 12) + end +end diff --git a/voyage/app/models/order_inbox_item.rb b/voyage/app/models/order_inbox_item.rb new file mode 100644 index 0000000..c897572 --- /dev/null +++ b/voyage/app/models/order_inbox_item.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class OrderInboxItem < Sbmt::Outbox::InboxItem + belongs_to :order, foreign_key: :event_key, primary_key: :uuid, optional: true, inverse_of: :inbox_items +end diff --git a/voyage/app/serializers/application_serializer.rb b/voyage/app/serializers/application_serializer.rb new file mode 100644 index 0000000..ef4e461 --- /dev/null +++ b/voyage/app/serializers/application_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationSerializer + include JSONAPI::Serializer +end diff --git a/voyage/app/serializers/order_inbox_item_serializer.rb b/voyage/app/serializers/order_inbox_item_serializer.rb new file mode 100644 index 0000000..da6ecec --- /dev/null +++ b/voyage/app/serializers/order_inbox_item_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class OrderInboxItemSerializer < ApplicationSerializer + set_id :uuid + + attributes :event_key, + :bucket, + :status, + :options, + :errors_count, + :error_log, + :processed_at, + :created_at, + :updated_at + + attribute :payload do |object| + OrderDecoder.decode(object.payload) + end +end diff --git a/voyage/app/serializers/order_serializer.rb b/voyage/app/serializers/order_serializer.rb new file mode 100644 index 0000000..e9e5b1e --- /dev/null +++ b/voyage/app/serializers/order_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class OrderSerializer < ApplicationSerializer + set_id :uuid + + attributes :name, + :qty, + :status, + :price, + :description, + :created_at, + :updated_at +end diff --git a/voyage/app/serializers/validation_errors_serializer.rb b/voyage/app/serializers/validation_errors_serializer.rb new file mode 100644 index 0000000..25a4afa --- /dev/null +++ b/voyage/app/serializers/validation_errors_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ValidationErrorsSerializer < ApplicationSerializer + set_id { nil } + + attribute :full_messages do |object| + object.full_messages + end + + attribute :details do |object| + object.details + end +end diff --git a/voyage/bin/grpc_gen b/voyage/bin/grpc_gen new file mode 100755 index 0000000..e1170d2 --- /dev/null +++ b/voyage/bin/grpc_gen @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +for file in "$@" +do + if [[ $(dirname $file) == *deps* ]] + then + outdir="pkg/client" + else + outdir="pkg/server" + fi + + echo "Generating gRPC code from $file to $outdir" + + bundle exec grpc_tools_ruby_protoc \ + -I api -I deps/services -I deps/googleapis \ + --ruby_out="$outdir" --grpc_out="$outdir" \ + "$file" +done diff --git a/voyage/bin/rails b/voyage/bin/rails new file mode 100755 index 0000000..6fb4e40 --- /dev/null +++ b/voyage/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/voyage/bin/rake b/voyage/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/voyage/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/voyage/bin/setup b/voyage/bin/setup new file mode 100755 index 0000000..d4d0c35 --- /dev/null +++ b/voyage/bin/setup @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +require "fileutils" + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts '== Installing dependencies ==' + system('bundle check') || system!('bundle install') + + # puts "\n== Copying sample files ==" + # unless File.exist?('config/database.yml') + # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' + # end + + puts "\n== Preparing database ==" + system! 'bin/rails db:prepare db:test:prepare' + + 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/voyage/build/Dockerfile b/voyage/build/Dockerfile new file mode 100644 index 0000000..18639e1 --- /dev/null +++ b/voyage/build/Dockerfile @@ -0,0 +1,63 @@ +ARG BUILD_BASE_IMAGE_REGISTRY +ARG BUILD_BASE_IMAGE_NAME +ARG BUILD_BASE_IMAGE_TAG +ARG BUILD_DEPENDENCIES + +# =============== Base image =============== +FROM ${BUILD_BASE_IMAGE_REGISTRY}/${BUILD_BASE_IMAGE_NAME}:${BUILD_BASE_IMAGE_TAG} as base-image + +ARG APP_PATH=/app +ARG RUBYGEMS_VERSION=3.5.6 +ARG BUNDLER_VERSION=2.5.5 + +ENV LANG=C.UTF-8 +ENV BUNDLE_PATH=${APP_PATH}/bundle +ENV GEM_HOME=${APP_PATH}/bundle + +WORKDIR $APP_PATH + +COPY Gemfile Gemfile.lock ./ + +RUN gem update --system ${RUBYGEMS_VERSION} \ + && gem install --default bundler -v ${BUNDLER_VERSION} + +RUN chown -R application:application /app && \ + bundle config --local deployment 'true' && \ + bundle config --local path "${BUNDLE_PATH}" && \ + bundle config --local clean 'true' && \ + bundle config --local no-cache 'true' + +# =============== Production image =============== +FROM base-image as bundle-prod + +ENV RAILS_ENV=production + +RUN bundle install \ + --without development test \ + --jobs=8 \ + --retry=3 + +# =============== Test image =============== +FROM base-image as bundle-development + +ENV RAILS_ENV=test + +RUN bundle install \ + --jobs=8 \ + --retry=3 + +# =============== Final image =============== +FROM bundle-${BUILD_DEPENDENCIES} AS final + +COPY . $APP_PATH +COPY --chown=application . $APP_PATH + +USER 10001 + +ENV PATH="${APP_PATH}/bin:${PATH}" + +ARG GIT_TAG=0.0.0-dev +ARG BUILD=HEAD + +ENV GIT_TAG=$GIT_TAG +ENV BUILD=$BUILD diff --git a/voyage/config.ru b/voyage/config.ru new file mode 100644 index 0000000..68dedaa --- /dev/null +++ b/voyage/config.ru @@ -0,0 +1,4 @@ +require ::File.expand_path("../config/environment", __FILE__) + +::HttpHealthCheck.run_server_async(port: ENV.fetch("HEALTH_CHECK_PORT").to_i) +run Rails.application diff --git a/voyage/config/application.rb b/voyage/config/application.rb new file mode 100644 index 0000000..ad704b3 --- /dev/null +++ b/voyage/config/application.rb @@ -0,0 +1,34 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +# require "active_job/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "active_job/railtie" +# require "action_mailer/railtie" +# require "action_view/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +# https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +if %w[development test].include?(ENV["RAILS_ENV"]) + Dotenv::Rails.load +end + +module Voyage + class Application < Rails::Application + config.load_defaults 7.1 + + config.api_only = true + config.time_zone = "Moscow" + + config.i18n.available_locales = [:en] + config.i18n.default_locale = :en + + config.active_job.queue_adapter = :sidekiq + end +end diff --git a/voyage/config/boot.rb b/voyage/config/boot.rb new file mode 100644 index 0000000..2820116 --- /dev/null +++ b/voyage/config/boot.rb @@ -0,0 +1,3 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/voyage/config/configs/application_config.rb b/voyage/config/configs/application_config.rb new file mode 100644 index 0000000..d4bd6d8 --- /dev/null +++ b/voyage/config/configs/application_config.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Base class for application config classes +class ApplicationConfig < Anyway::Config + class << self + # Make it possible to access a singleton config instance + # via class methods (i.e., without explicitly calling `instance`) + delegate_missing_to :instance + + private + + # Returns a singleton config instance + def instance + @instance ||= new + end + end +end diff --git a/voyage/config/configs/redis_config.rb b/voyage/config/configs/redis_config.rb new file mode 100644 index 0000000..c18cf18 --- /dev/null +++ b/voyage/config/configs/redis_config.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "uri" + +class RedisConfig < ApplicationConfig + config_name :redis + env_prefix :redis + + attr_config :url, :db, :db_sidekiq, :db_outbox, :db_cache + + required :url, :db, :db_sidekiq, :db_outbox, :db_cache + + def connection_uri(db_name = :db) + uri = URI(dsn) + uri.path = "/#{public_send(db_name)}" + uri.to_s + end + + def connection_options(db_name = :db) + { + url: connection_uri(db_name), + reconnect_attempts: [0, 0.05, 0.1, 0.5], + db: public_send(db_name) + } + end +end diff --git a/voyage/config/database.yml b/voyage/config/database.yml new file mode 100644 index 0000000..a984b6a --- /dev/null +++ b/voyage/config/database.yml @@ -0,0 +1,24 @@ +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %> + prepared_statements: false + advisory_locks: false + url: <%= ENV["DATABASE_URL"] %> + +development: + <<: *default + database: voyage_development + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: voyage_test + +staging: + <<: *default + +production: + <<: *default diff --git a/voyage/config/environment.rb b/voyage/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/voyage/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/voyage/config/environments/development.rb b/voyage/config/environments/development.rb new file mode 100644 index 0000000..73fd67a --- /dev/null +++ b/voyage/config/environments/development.rb @@ -0,0 +1,77 @@ +require "active_support/core_ext/integer/time" + +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 any time + # it changes. 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 = ENV["CODE_RELOAD"] == "no" + + # Do not eager load code on boot. + config.eager_load = ENV["EAGER_LOAD"] == "yes" + + # 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 ENV["CACHE"] == "yes" + config.cache_store = :redis_cache_store, {url: RedisConfig.connection_uri, expires_in: 5.minutes} + 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 + + # 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 exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "debug").to_s.downcase + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Use an evented file watcher to asynchronously detect changes in source code, + # routes, locales, etc. This feature depends on the listen gem. + unless ENV["DEV_SKIP_LISTEN_GEM"] + config.file_watcher = ActiveSupport::EventedFileUpdateChecker + end + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + config.rails_semantic_logger.semantic = false + config.rails_semantic_logger.started = true + config.rails_semantic_logger.processing = true + config.rails_semantic_logger.rendered = true + + config.semantic_logger.add_appender( + io: $stdout, + level: config.log_level, + formatter: config.rails_semantic_logger.format + ) +end diff --git a/voyage/config/environments/production.rb b/voyage/config/environments/production.rb new file mode 100644 index 0000000..a0e71d4 --- /dev/null +++ b/voyage/config/environments/production.rb @@ -0,0 +1,98 @@ +require "active_support/core_ext/integer/time" + +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 + + # 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? + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.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 + + # 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 + + # Include generic and useful information about system operation, but avoid logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info").to_s.downcase + + # Use a different cache store in production. + config.cache_store = :redis_cache_store, {url: RedisConfig.connection_uri, expires_in: 1.hour} + + # 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 = Rails.application.config.sbmt_app.manifest.fetch(:name) + + # 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 + + # Log disallowed deprecations. + config.active_support.disallowed_deprecation = :log + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Use a different logger for distributed setups. + # require "syslog/logger" + # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Inserts middleware to perform automatic connection switching. + # The `database_selector` hash is used to pass options to the DatabaseSelector + # middleware. The `delay` is used to determine how long to wait after a write + # to send a subsequent read to the primary. + # + # The `database_resolver` class is used by the middleware to determine which + # database is appropriate to use based on the time delay. + # + # The `database_resolver_context` class is used by the middleware to set + # timestamps for the last write to the primary. The resolver uses the context + # class timestamps to determine how long to wait before reading from the + # replica. + # + # By default Rails will store a last write timestamp in the session. The + # DatabaseSelector middleware is designed as such you can define your own + # strategy for connection switching and pass that into the middleware through + # these configuration options. + # config.active_record.database_selector = { delay: 2.seconds } + # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver + # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session +end diff --git a/voyage/config/environments/staging.rb b/voyage/config/environments/staging.rb new file mode 100644 index 0000000..ad87d7e --- /dev/null +++ b/voyage/config/environments/staging.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require_relative "production" + +# Rails.application.configure do +# # paste custom config +# end diff --git a/voyage/config/environments/test.rb b/voyage/config/environments/test.rb new file mode 100644 index 0000000..fde0bff --- /dev/null +++ b/voyage/config/environments/test.rb @@ -0,0 +1,57 @@ +require "active_support/core_ext/integer/time" + +# 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! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + config.cache_classes = ENV["CODE_RELOAD"] == "no" + # config.action_view.cache_template_loading = 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 = ENV["EAGER_LOAD"] == "yes" + + # 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 + config.cache_store = :null_store + + # 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 + + # 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 + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true +end diff --git a/voyage/config/initializers/0_redis.rb b/voyage/config/initializers/0_redis.rb new file mode 100644 index 0000000..6736b41 --- /dev/null +++ b/voyage/config/initializers/0_redis.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +PRIMARY_REDIS = ConnectionPool::Wrapper.new(size: ENV.fetch("DATABASE_POOL_SIZE", 5).to_i) do + Redis.new(RedisConfig.connection_options(:db)) +end diff --git a/voyage/config/initializers/backtrace_silencers.rb b/voyage/config/initializers/backtrace_silencers.rb new file mode 100644 index 0000000..ec1faa6 --- /dev/null +++ b/voyage/config/initializers/backtrace_silencers.rb @@ -0,0 +1 @@ +Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] diff --git a/voyage/config/initializers/filter_parameter_logging.rb b/voyage/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..6138714 --- /dev/null +++ b/voyage/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,3 @@ +Rails.application.config.filter_parameters += [ + :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/voyage/config/initializers/http_health_check.rb b/voyage/config/initializers/http_health_check.rb new file mode 100644 index 0000000..992a16f --- /dev/null +++ b/voyage/config/initializers/http_health_check.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# TODO: Move to sbmt-app? +HttpHealthCheck.configure do |c| + HttpHealthCheck.add_builtin_probes(c) + + c.probe "/readiness/puma" do |env| + [200, {}, [Puma.stats]] + end + + c.probe "/readiness/schked" do |env| + [200, {}, [Schked.worker.send(:scheduler).uptime_s]] + end +end diff --git a/voyage/config/initializers/outbox.rb b/voyage/config/initializers/outbox.rb new file mode 100644 index 0000000..a32b31b --- /dev/null +++ b/voyage/config/initializers/outbox.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.config.outbox.tap do |config| + config.redis = RedisConfig.connection_options(:db_outbox) +end diff --git a/voyage/config/initializers/pagy.rb b/voyage/config/initializers/pagy.rb new file mode 100644 index 0000000..4ff2841 --- /dev/null +++ b/voyage/config/initializers/pagy.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +# Pagy initializer file (6.0.4) +# Customize only what you really need and notice that the core Pagy works also without any of the following lines. +# Should you just cherry pick part of this file, please maintain the require-order of the extras + +# Pagy DEFAULT Variables +# See https://ddnexus.github.io/pagy/docs/api/pagy#variables +# All the Pagy::DEFAULT are set for all the Pagy instances but can be overridden per instance by just passing them to +# Pagy.new|Pagy::Countless.new|Pagy::Calendar::*.new or any of the #pagy* controller methods + +# Instance variables +# See https://ddnexus.github.io/pagy/docs/api/pagy#instance-variables +# Pagy::DEFAULT[:page] = 1 # default +Pagy::DEFAULT[:items] = Rails.env.test? ? 2 : 20 +# Pagy::DEFAULT[:outset] = 0 # default + +# Other Variables +# See https://ddnexus.github.io/pagy/docs/api/pagy#other-variables +# Pagy::DEFAULT[:size] = [1,4,4,1] # default +# Pagy::DEFAULT[:page_param] = :page # default +# The :params can be also set as a lambda e.g ->(params){ params.exclude('useless').merge!('custom' => 'useful') } +# Pagy::DEFAULT[:params] = {} # default +# Pagy::DEFAULT[:fragment] = '#fragment' # example +# Pagy::DEFAULT[:link_extra] = 'data-remote="true"' # example +# Pagy::DEFAULT[:i18n_key] = 'pagy.item_name' # default +# Pagy::DEFAULT[:cycle] = true # example +# Pagy::DEFAULT[:request_path] = "/foo" # example + +# Extras +# See https://ddnexus.github.io/pagy/categories/extra + +# Backend Extras + +# Arel extra: For better performance utilizing grouped ActiveRecord collections: +# See: https://ddnexus.github.io/pagy/docs/extras/arel +# require 'pagy/extras/arel' + +# Array extra: Paginate arrays efficiently, avoiding expensive array-wrapping and without overriding +# See https://ddnexus.github.io/pagy/docs/extras/array +# require 'pagy/extras/array' + +# Calendar extra: Add pagination filtering by calendar time unit (year, quarter, month, week, day) +# See https://ddnexus.github.io/pagy/docs/extras/calendar +# require 'pagy/extras/calendar' +# Default for each unit +# Pagy::Calendar::Year::DEFAULT[:order] = :asc # Time direction of pagination +# Pagy::Calendar::Year::DEFAULT[:format] = '%Y' # strftime format +# +# Pagy::Calendar::Quarter::DEFAULT[:order] = :asc # Time direction of pagination +# Pagy::Calendar::Quarter::DEFAULT[:format] = '%Y-Q%q' # strftime format +# +# Pagy::Calendar::Month::DEFAULT[:order] = :asc # Time direction of pagination +# Pagy::Calendar::Month::DEFAULT[:format] = '%Y-%m' # strftime format +# +# Pagy::Calendar::Week::DEFAULT[:order] = :asc # Time direction of pagination +# Pagy::Calendar::Week::DEFAULT[:format] = '%Y-%W' # strftime format +# +# Pagy::Calendar::Day::DEFAULT[:order] = :asc # Time direction of pagination +# Pagy::Calendar::Day::DEFAULT[:format] = '%Y-%m-%d' # strftime format +# +# Uncomment the following lines, if you need calendar localization without using the I18n extra +# module LocalizePagyCalendar +# def localize(time, opts) +# ::I18n.l(time, **opts) +# end +# end +# Pagy::Calendar.prepend LocalizePagyCalendar + +# Countless extra: Paginate without any count, saving one query per rendering +# See https://ddnexus.github.io/pagy/docs/extras/countless +# require 'pagy/extras/countless' +# Pagy::DEFAULT[:countless_minimal] = false # default (eager loading) + +# Elasticsearch Rails extra: Paginate `ElasticsearchRails::Results` objects +# See https://ddnexus.github.io/pagy/docs/extras/elasticsearch_rails +# Default :pagy_search method: change only if you use also +# the searchkick or meilisearch extra that defines the same +# Pagy::DEFAULT[:elasticsearch_rails_pagy_search] = :pagy_search +# Default original :search method called internally to do the actual search +# Pagy::DEFAULT[:elasticsearch_rails_search] = :search +# require 'pagy/extras/elasticsearch_rails' + +# Headers extra: http response headers (and other helpers) useful for API pagination +# See http://ddnexus.github.io/pagy/extras/headers +require "pagy/extras/headers" +# Pagy::DEFAULT[:headers] = { page: 'Current-Page', +# items: 'Page-Items', +# count: 'Total-Count', +# pages: 'Total-Pages' } # default + +# Meilisearch extra: Paginate `Meilisearch` result objects +# See https://ddnexus.github.io/pagy/docs/extras/meilisearch +# Default :pagy_search method: change only if you use also +# the elasticsearch_rails or searchkick extra that define the same method +# Pagy::DEFAULT[:meilisearch_pagy_search] = :pagy_search +# Default original :search method called internally to do the actual search +# Pagy::DEFAULT[:meilisearch_search] = :ms_search +# require 'pagy/extras/meilisearch' + +# Metadata extra: Provides the pagination metadata to Javascript frameworks like Vue.js, react.js, etc. +# See https://ddnexus.github.io/pagy/docs/extras/metadata +# you must require the frontend helpers internal extra (BEFORE the metadata extra) ONLY if you need also the :sequels +# require 'pagy/extras/frontend_helpers' +# require 'pagy/extras/metadata' +# For performance reasons, you should explicitly set ONLY the metadata you use in the frontend +# Pagy::DEFAULT[:metadata] = %i[scaffold_url page prev next last] # example + +# Searchkick extra: Paginate `Searchkick::Results` objects +# See https://ddnexus.github.io/pagy/docs/extras/searchkick +# Default :pagy_search method: change only if you use also +# the elasticsearch_rails or meilisearch extra that defines the same +# DEFAULT[:searchkick_pagy_search] = :pagy_search +# Default original :search method called internally to do the actual search +# Pagy::DEFAULT[:searchkick_search] = :search +# require 'pagy/extras/searchkick' +# uncomment if you are going to use Searchkick.pagy_search +# Searchkick.extend Pagy::Searchkick + +# Frontend Extras + +# Bootstrap extra: Add nav, nav_js and combo_nav_js helpers and templates for Bootstrap pagination +# See https://ddnexus.github.io/pagy/docs/extras/bootstrap +# require 'pagy/extras/bootstrap' + +# Bulma extra: Add nav, nav_js and combo_nav_js helpers and templates for Bulma pagination +# See https://ddnexus.github.io/pagy/docs/extras/bulma +# require 'pagy/extras/bulma' + +# Foundation extra: Add nav, nav_js and combo_nav_js helpers and templates for Foundation pagination +# See https://ddnexus.github.io/pagy/docs/extras/foundation +# require 'pagy/extras/foundation' + +# Materialize extra: Add nav, nav_js and combo_nav_js helpers for Materialize pagination +# See https://ddnexus.github.io/pagy/docs/extras/materialize +# require 'pagy/extras/materialize' + +# Navs extra: Add nav_js and combo_nav_js javascript helpers +# Notice: the other frontend extras add their own framework-styled versions, +# so require this extra only if you need the unstyled version +# See https://ddnexus.github.io/pagy/docs/extras/navs +# require 'pagy/extras/navs' + +# Semantic extra: Add nav, nav_js and combo_nav_js helpers for Semantic UI pagination +# See https://ddnexus.github.io/pagy/docs/extras/semantic +# require 'pagy/extras/semantic' + +# UIkit extra: Add nav helper and templates for UIkit pagination +# See https://ddnexus.github.io/pagy/docs/extras/uikit +# require 'pagy/extras/uikit' + +# Multi size var used by the *_nav_js helpers +# See https://ddnexus.github.io/pagy/docs/extras/navs#steps +# Pagy::DEFAULT[:steps] = { 0 => [2,3,3,2], 540 => [3,5,5,3], 720 => [5,7,7,5] } # example + +# Feature Extras + +# Gearbox extra: Automatically change the number of items per page depending on the page number +# See https://ddnexus.github.io/pagy/docs/extras/gearbox +# require 'pagy/extras/gearbox' +# set to false only if you want to make :gearbox_extra an opt-in variable +# Pagy::DEFAULT[:gearbox_extra] = false # default true +# Pagy::DEFAULT[:gearbox_items] = [15, 30, 60, 100] # default + +# Items extra: Allow the client to request a custom number of items per page with an optional selector UI +# See https://ddnexus.github.io/pagy/docs/extras/items +# require 'pagy/extras/items' +# set to false only if you want to make :items_extra an opt-in variable +# Pagy::DEFAULT[:items_extra] = false # default true +# Pagy::DEFAULT[:items_param] = :items # default +# Pagy::DEFAULT[:max_items] = 100 # default + +# Overflow extra: Allow for easy handling of overflowing pages +# See https://ddnexus.github.io/pagy/docs/extras/overflow +# require 'pagy/extras/overflow' +# Pagy::DEFAULT[:overflow] = :empty_page # default (other options: :last_page and :exception) + +# Support extra: Extra support for features like: incremental, infinite, auto-scroll pagination +# See https://ddnexus.github.io/pagy/docs/extras/support +# require 'pagy/extras/support' + +# Trim extra: Remove the page=1 param from links +# See https://ddnexus.github.io/pagy/docs/extras/trim +# require 'pagy/extras/trim' +# set to false only if you want to make :trim_extra an opt-in variable +# Pagy::DEFAULT[:trim_extra] = false # default true + +# Standalone extra: Use pagy in non Rack environment/gem +# See https://ddnexus.github.io/pagy/docs/extras/standalone +# require 'pagy/extras/standalone' +# Pagy::DEFAULT[:url] = 'http://www.example.com/subdir' # optional default + +# Rails +# Enable the .js file required by the helpers that use javascript +# (pagy*_nav_js, pagy*_combo_nav_js, and pagy_items_selector_js) +# See https://ddnexus.github.io/pagy/docs/api/javascript + +# With the asset pipeline +# Sprockets need to look into the pagy javascripts dir, so add it to the assets paths +# Rails.application.config.assets.paths << Pagy.root.join('javascripts') + +# I18n + +# Pagy internal I18n: ~18x faster using ~10x less memory than the i18n gem +# See https://ddnexus.github.io/pagy/docs/api/i18n +# Notice: No need to configure anything in this section if your app uses only "en" +# or if you use the i18n extra below +# +# Examples: +# load the "de" built-in locale: +# Pagy::I18n.load(locale: 'de') +# +# load the "de" locale defined in the custom file at :filepath: +# Pagy::I18n.load(locale: 'de', filepath: 'path/to/pagy-de.yml') +# +# load the "de", "en" and "es" built-in locales: +# (the first passed :locale will be used also as the default_locale) +# Pagy::I18n.load({ locale: 'de' }, +# { locale: 'en' }, +# { locale: 'es' }) +# +# load the "en" built-in locale, a custom "es" locale, +# and a totally custom locale complete with a custom :pluralize proc: +# (the first passed :locale will be used also as the default_locale) +# Pagy::I18n.load({ locale: 'en' }, +# { locale: 'es', filepath: 'path/to/pagy-es.yml' }, +# { locale: 'xyz', # not built-in +# filepath: 'path/to/pagy-xyz.yml', +# pluralize: lambda{ |count| ... } ) + +# I18n extra: uses the standard i18n gem which is ~18x slower using ~10x more memory +# than the default pagy internal i18n (see above) +# See https://ddnexus.github.io/pagy/docs/extras/i18n +# require 'pagy/extras/i18n' + +# Default i18n key +# Pagy::DEFAULT[:i18n_key] = 'pagy.item_name' # default + +# When you are done setting your own default freeze it, so it will not get changed accidentally +Pagy::DEFAULT.freeze diff --git a/voyage/config/initializers/schked.rb b/voyage/config/initializers/schked.rb new file mode 100644 index 0000000..273abe4 --- /dev/null +++ b/voyage/config/initializers/schked.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Schked.config.tap do |config| + config.redis = RedisConfig.connection_options(:db) +end diff --git a/voyage/config/initializers/sentry.rb b/voyage/config/initializers/sentry.rb new file mode 100644 index 0000000..bf44f14 --- /dev/null +++ b/voyage/config/initializers/sentry.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.breadcrumbs_logger = %i[active_support_logger http_logger] + config.enabled_environments = %w[staging production] + config.excluded_exceptions += %w[ActionController::RoutingError] + config.traces_sample_rate = 1.0 + config.release = ENV["APP_VERSION"] +end diff --git a/voyage/config/initializers/sidekiq.rb b/voyage/config/initializers/sidekiq.rb new file mode 100644 index 0000000..c7c9fc7 --- /dev/null +++ b/voyage/config/initializers/sidekiq.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "sidekiq/web" + +Sidekiq.configure_server do |config| + config.redis = RedisConfig.connection_options(:db_sidekiq) + + ::HttpHealthCheck.run_server_async(port: ENV.fetch("HEALTH_CHECK_PORT").to_i) +end + +Sidekiq.configure_client do |config| + config.redis = RedisConfig.connection_options(:db_sidekiq) +end diff --git a/voyage/config/initializers/wrap_parameters.rb b/voyage/config/initializers/wrap_parameters.rb new file mode 100644 index 0000000..8b64a78 --- /dev/null +++ b/voyage/config/initializers/wrap_parameters.rb @@ -0,0 +1,3 @@ +ActiveSupport.on_load(:action_controller) do + wrap_parameters format: [:json] +end diff --git a/voyage/config/initializers/yabeda.rb b/voyage/config/initializers/yabeda.rb new file mode 100644 index 0000000..7caf2b9 --- /dev/null +++ b/voyage/config/initializers/yabeda.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Yabeda.configure do + default_tag :rails_environment, Rails.env + default_tag :service_name, "Voyage" +end diff --git a/voyage/config/kafka_consumer.yml b/voyage/config/kafka_consumer.yml new file mode 100644 index 0000000..d04d963 --- /dev/null +++ b/voyage/config/kafka_consumer.yml @@ -0,0 +1,40 @@ +default: &default + auth: + kind: 'sasl_plaintext' + sasl_mechanism: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').first %> + sasl_username: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').second %> + sasl_password: <%= ENV.fetch('KAFKA_SASL_DSN'){ 'SCRAM-SHA-512:kafka_login:kafka_password' }.split(':').last %> + client_id: voyage + shutdown_timeout: 120 + kafka: + servers: <%= ENV.fetch('KAFKA_BROKERS'){ 'kafka:9092' } %> + kafka_options: + auto.offset.reset: 'latest' + consumer_groups: + orders: + name: <%= ENV.fetch('VOYAGE__KAFKA__ORDERS_CONSUMER_GROUP_NAME'){ 'orders' } %><%= ENV.fetch('QUEST__KAFKA__CONSUMER_GROUP_SUFFIX'){ '' } %> + topics: + - name: <%= ENV.fetch('VOYAGE__KAFKA__TOPICS__ORDERS'){ 'yc.quest-stand.orders.0' } %> + consumer: + klass: "Sbmt::KafkaConsumer::InboxConsumer" + init_attrs: + name: "Orders" + inbox_item: "OrderInboxItem" + probes: + port: <%= ENV.fetch('HEALTH_CHECK_PORT') { '9394' } %> + metrics: + port: <%= ENV.fetch('PROMETHEUS_EXPORTER_PORT') { '9090' } %> +development: + <<: *default + auth: + kind: 'plaintext' +test: + <<: *default + deliver: false + wait_on_queue_full: false + auth: + kind: 'plaintext' +staging: &staging + <<: *default +production: + <<: *staging diff --git a/voyage/config/logging.yml b/voyage/config/logging.yml new file mode 100644 index 0000000..a5d4b9b --- /dev/null +++ b/voyage/config/logging.yml @@ -0,0 +1,22 @@ +default: &default + enabled: true + max_nesting_level: 1 + allow_arrays: false + schema: + payload: {} + named_tags: + backtrace: text + box_name: text + box_partition: long + box_type: text + trace_id: text + worker: long +development: + <<: *default + enabled: false +test: + <<: *default +staging: &staging + <<: *default +production: + <<: *staging diff --git a/voyage/config/outbox.yml b/voyage/config/outbox.yml new file mode 100644 index 0000000..f205eff --- /dev/null +++ b/voyage/config/outbox.yml @@ -0,0 +1,31 @@ +default: &default + owner: "@ruby-platform" # Your team name in Mattermost + bucket_size: 16 + probes: + port: <%= ENV.fetch("HEALTH_CHECK_PORT"){ 5555 } %> + + inbox_items: + order_inbox_item: + partition_size: <%= ENV.fetch('VOYAGE__ORDER_INBOX_ITEM__PARTITION_SIZE'){ '1' } %> + partition_strategy: number + retention: P3D + max_retries: <%= ENV.fetch('VOYAGE__ORDER_INBOX_ITEM__MAX_RETRIES'){ '7' } %> + retry_strategies: + - exponential_backoff + transports: + inbox_importers: + target: order + +development: + <<: *default + +test: + <<: *default + bucket_size: 2 + +staging: + <<: *default + +production: + <<: *default + bucket_size: 128 diff --git a/voyage/config/puma.rb b/voyage/config/puma.rb new file mode 100644 index 0000000..bce93a8 --- /dev/null +++ b/voyage/config/puma.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +app_root = File.expand_path(File.join(File.dirname(__FILE__), "..")) + +# 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. +# +env = ENV.fetch("RAILS_ENV", "development") +# bind ENV.fetch("PUMA_SOCKET", "unix://#{app_root}/tmp/sockets/puma.sock") + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +workers ENV.fetch("PUMA_CONCURRENCY", (env == "development") ? 0 : 10).to_i +worker_timeout (env == "production") ? 3600 : 10 + +threads_count = ENV.fetch("RAILS_MAX_THREADS", 2).to_i +threads(threads_count, threads_count) + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PUMA_PORT", 3000) + +# Specifies the `environment` that Puma will run in. +# +environment env + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PUMA_PIDFILE", "#{app_root}/tmp/pids/puma.pid") +state_path ENV.fetch("PUMA_STATEFILE", "#{app_root}/tmp/pids/puma.state") + +# 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! + +on_worker_boot do + SemanticLogger.reopen +end + +# Allow puma to be restarted by `bin/rails restart` command. +activate_control_app ENV.fetch("PUMA_CTL_SOCKET", "unix:///var/run/pumactl.sock") +plugin :tmp_restart +plugin :yabeda +plugin :yabeda_prometheus diff --git a/voyage/config/redis.yml b/voyage/config/redis.yml new file mode 100644 index 0000000..b25efdf --- /dev/null +++ b/voyage/config/redis.yml @@ -0,0 +1,20 @@ +default: &default + db: 0 + db_sidekiq: 1 + db_cache: 2 + db_outbox: 3 + +development: + <<: *default + +test: + db: 15 + db_sidekiq: 15 + db_outbox: 15 + db_cache: 15 + +staging: + <<: *default + +production: + <<: *default diff --git a/voyage/config/routes.rb b/voyage/config/routes.rb new file mode 100644 index 0000000..a167d91 --- /dev/null +++ b/voyage/config/routes.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + root "welcome#index" + + namespace :api do + namespace :v1 do + resources :orders, only: [:index, :show] do + scope module: "orders" do + resource :processing, only: :create + resources :inbox_items, only: [:index] + end + end + end + end +end diff --git a/voyage/config/schedule.rb b/voyage/config/schedule.rb new file mode 100644 index 0000000..fad27b2 --- /dev/null +++ b/voyage/config/schedule.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Schked schedule https://github.com/bibendi/schked +# WARNING: Keep your tasks as fast as you can. The best choise are Sidekiq jobs. + +# Example task +# cron "*/1 * * * *", as: "UpdateMerchantStatusRequestsJob", timeout: "60s", overlap: false do +# UpdateMerchantStatusRequestsJob.perform_async +# end diff --git a/voyage/config/sidekiq.yml b/voyage/config/sidekiq.yml new file mode 100644 index 0000000..c5a4f59 --- /dev/null +++ b/voyage/config/sidekiq.yml @@ -0,0 +1,4 @@ +:concurrency: <%= ENV.fetch('RAILS_MAX_THREADS', 4).to_i %> +:queues: + - default + - inbox diff --git a/voyage/db/migrate/20230511132409_create_orders.rb b/voyage/db/migrate/20230511132409_create_orders.rb new file mode 100644 index 0000000..f95bba8 --- /dev/null +++ b/voyage/db/migrate/20230511132409_create_orders.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateOrders < ActiveRecord::Migration[7.0] + def change + create_enum :order_status, %w[completed processed] + + create_table :orders do |t| + t.string :uuid, null: false + t.string :name, null: false + t.integer :qty, null: false + t.enum :status, enum_type: :order_status, null: false, default: :completed + t.decimal :price, precision: 10, scale: 2, null: false + t.text :description + t.timestamps null: false + + t.index :uuid, unique: true + end + end +end diff --git a/voyage/db/migrate/20230512065059_create_order_inbox_items.rb b/voyage/db/migrate/20230512065059_create_order_inbox_items.rb new file mode 100644 index 0000000..f3a8aee --- /dev/null +++ b/voyage/db/migrate/20230512065059_create_order_inbox_items.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreateOrderInboxItems < ActiveRecord::Migration[7.0] + def change + create_table :order_inbox_items do |t| + t.string :uuid, null: false + t.string :event_key, null: false + t.integer :bucket, null: false + t.integer :status, null: false, default: 0 + t.jsonb :options + t.binary :payload, null: false + t.integer :errors_count, null: false, default: 0 + t.text :error_log + t.timestamp :processed_at + t.timestamps null: false + end + + add_index :order_inbox_items, :uuid, unique: true + add_index :order_inbox_items, [:status, :bucket] + add_index :order_inbox_items, :event_key + add_index :order_inbox_items, :created_at + end +end diff --git a/voyage/db/schema.rb b/voyage/db/schema.rb new file mode 100644 index 0000000..f99db74 --- /dev/null +++ b/voyage/db/schema.rb @@ -0,0 +1,51 @@ +# 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[7.1].define(version: 2023_05_12_065059) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + # Custom types defined in this database. + # Note that some types may not work with other database engines. Be careful if changing database. + create_enum "order_status", ["completed", "processed"] + + create_table "order_inbox_items", force: :cascade do |t| + t.string "uuid", null: false + t.string "event_key", null: false + t.integer "bucket", null: false + t.integer "status", default: 0, null: false + t.jsonb "options" + t.binary "payload", null: false + t.integer "errors_count", default: 0, null: false + t.text "error_log" + t.datetime "processed_at", precision: nil + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_order_inbox_items_on_created_at" + t.index ["event_key"], name: "index_order_inbox_items_on_event_key" + t.index ["status", "bucket"], name: "index_order_inbox_items_on_status_and_bucket" + t.index ["uuid"], name: "index_order_inbox_items_on_uuid", unique: true + end + + create_table "orders", force: :cascade do |t| + t.string "uuid", null: false + t.string "name", null: false + t.integer "qty", null: false + t.enum "status", default: "completed", null: false, enum_type: "order_status" + t.decimal "price", precision: 10, scale: 2, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["uuid"], name: "index_orders_on_uuid", unique: true + end + +end diff --git a/voyage/deps/services/seeker/events/order.proto b/voyage/deps/services/seeker/events/order.proto new file mode 100644 index 0000000..0c22e16 --- /dev/null +++ b/voyage/deps/services/seeker/events/order.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "google/type/money.proto"; + +package protobuf.order_data; + +message Order { + enum OrderStatus { + PENDING = 0; + COMPLETED = 1; + CANCELED = 2; + } + + string id = 1; + string name = 2; + int32 qty = 3; + OrderStatus status = 4; + string description = 5; + google.type.Money price = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; +} diff --git a/voyage/dip.yml b/voyage/dip.yml new file mode 100644 index 0000000..b9a1773 --- /dev/null +++ b/voyage/dip.yml @@ -0,0 +1,76 @@ +version: '7.5' + +environment: + WORK_DIR: /app/${DIP_WORK_DIR_REL_PATH} + +compose: + files: + - ./.dockerdev/docker-compose.yml + - ./.dockerdev/docker-compose.local.yml + +interaction: + bash: + description: Open a Bash shell + service: backend + command: bash + + bundle: + description: Run Bundler commands + service: backend + command: bundle + + rake: + description: Run Rake commands + service: backend + command: bundle exec rake + + rails: + description: Run Rails commands + service: backend + command: bundle exec rails + subcommands: + s: + description: Run puma available at http://localhost:3000 + service: puma + command: bundle exec puma + compose: + run_options: [service-ports, use-aliases] + + rspec: + description: Run RSpec commands within test environment + service: backend + environment: + RAILS_ENV: test + command: bundle exec rspec + + rubocop: + description: Lint ruby files + service: backend + command: bundle exec rubocop + + psql: + description: Open Postgres console + service: postgres + default_args: voyage_development + command: env PGPASSWORD=keepinsecret psql -h postgres -U postgres + + redis: + description: Open a Redis console + service: redis + command: redis-cli -h redis + + grpc_gen: + description: Generate services from .proto files + service: backend + command: bash -c 'bin/grpc_gen $@' 'grpc_gen' + + setup: + description: Install deps + service: backend + command: bin/setup + +provision: + - cp -f lefthook-local.dip_example.yml lefthook-local.yml + - touch .env.local + - dip compose down --volumes + - dip setup diff --git a/voyage/lefthook-local.dip_example.yml b/voyage/lefthook-local.dip_example.yml new file mode 100644 index 0000000..2b1f70e --- /dev/null +++ b/voyage/lefthook-local.dip_example.yml @@ -0,0 +1,4 @@ +pre-commit: + commands: + rubocop: + run: dip {cmd} diff --git a/voyage/lefthook.yml b/voyage/lefthook.yml new file mode 100644 index 0000000..365d158 --- /dev/null +++ b/voyage/lefthook.yml @@ -0,0 +1,6 @@ +pre-commit: + commands: + rubocop: + tags: backend + glob: "**/*.rb" + run: bundle exec rubocop -A --force-exclusion {staged_files} && git add {staged_files} diff --git a/voyage/lib/seeds/dsl.rb b/voyage/lib/seeds/dsl.rb new file mode 100644 index 0000000..453f198 --- /dev/null +++ b/voyage/lib/seeds/dsl.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Seeds + SPACE = " " + + class << self + attr_accessor :depth + + def announce(msg) + $stdout.puts pad(msg) + + return unless block_given? + + self.depth += 1 + yield + self.depth -= 1 + end + + def pad(msg) + return msg if depth.zero? + + "#{SPACE * depth * 2} ↳ #{msg}" + end + + def pretty_params(params) + params.each_with_object([]) do |(key, val), acc| + acc << if val.is_a?(ActiveRecord::Base) + "#{key}: #{val.class.model_name.human}(##{val.id})" + else + "#{key}: #{val}" + end + end.join(", ") + end + end + + self.depth = 0 + + module DSL + refine Object do + def announce(msg, &block) + Seeds.announce(msg, &block) + end + + def create(factory, *traits, **params) + FactoryBot.create(factory, *traits, **params).tap do |record| + traits_msg = traits.any? ? " (#{traits.join(", ")})" : "" + params_msg = params.any? ? " with #{Seeds.pretty_params(params)}" : "" + + announce "created #{factory.to_s.camelize}(##{record.id})#{traits_msg}#{params_msg}" + end + end + + def create_batch(batch_size, factory, *traits, **params) + created = [] + batch_size.times do |n| + nth_params = params.clone + nth_params.keys.each do |k| + next unless nth_params[k].is_a?(String) + nth_params[k] = nth_params[k].gsub("%%", "%03d" % n) + end + created << FactoryBot.create(factory, *traits, **nth_params) + end + traits_msg = traits.any? ? " (#{traits.join(", ")})" : "" + params_msg = params.any? ? " with #{Seeds.pretty_params(params)}" : "" + + announce "created batch of #{created.size} #{factory.to_s.camelize}s#{traits_msg}#{params_msg}" + created + end + end + end +end diff --git a/voyage/log/.keep b/voyage/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/voyage/pkg/client/.keep b/voyage/pkg/client/.keep new file mode 100644 index 0000000..e69de29 diff --git a/voyage/pkg/client/quest/events/order_pb.rb b/voyage/pkg/client/quest/events/order_pb.rb new file mode 100644 index 0000000..8859613 --- /dev/null +++ b/voyage/pkg/client/quest/events/order_pb.rb @@ -0,0 +1,34 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: quest/events/order.proto + +require "google/protobuf" + +require "google/protobuf/timestamp_pb" +require "google/type/money_pb" + +Google::Protobuf::DescriptorPool.generated_pool.build do + add_file("quest/events/order.proto", syntax: :proto3) do + add_message "protobuf.order_data.Order" do + optional :id, :string, 1 + optional :name, :string, 2 + optional :qty, :int32, 3 + optional :status, :enum, 4, "protobuf.order_data.Order.OrderStatus" + optional :description, :string, 5 + optional :price, :message, 6, "google.type.Money" + optional :created_at, :message, 7, "google.protobuf.Timestamp" + optional :updated_at, :message, 8, "google.protobuf.Timestamp" + end + add_enum "protobuf.order_data.Order.OrderStatus" do + value :PENDING, 0 + value :COMPLETED, 1 + value :CANCELED, 2 + end + end +end + +module Protobuf + module OrderData + Order = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("protobuf.order_data.Order").msgclass + Order::OrderStatus = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("protobuf.order_data.Order.OrderStatus").enummodule + end +end diff --git a/voyage/pkg/server/events/.keep b/voyage/pkg/server/events/.keep new file mode 100644 index 0000000..e69de29 diff --git a/voyage/public/robots.txt b/voyage/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/voyage/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/voyage/spec/controllers/api/v1/orders/inbox_items_controller_spec.rb b/voyage/spec/controllers/api/v1/orders/inbox_items_controller_spec.rb new file mode 100644 index 0000000..6df67c8 --- /dev/null +++ b/voyage/spec/controllers/api/v1/orders/inbox_items_controller_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +describe Api::V1::Orders::InboxItemsController do + let_it_be(:order) { create(:order) } + let_it_be(:item_1) { create(:order_inbox_item, order: order) } + let_it_be(:item_2) { create(:order_inbox_item, order: order) } + + let(:data) { response.parsed_body["data"] } + + describe "#index" do + it "returns order's inbox items" do + get :index, params: {order_id: order.uuid} + + expect(response).to be_successful + expect(data.size).to eq 2 + expect(data[0]["id"]).to eq item_2.uuid + expect(data[1]["id"]).to eq item_1.uuid + end + end +end diff --git a/voyage/spec/controllers/api/v1/orders/processings_controller_spec.rb b/voyage/spec/controllers/api/v1/orders/processings_controller_spec.rb new file mode 100644 index 0000000..66cb733 --- /dev/null +++ b/voyage/spec/controllers/api/v1/orders/processings_controller_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +describe Api::V1::Orders::ProcessingsController do + let_it_be(:order, reload: true) { create(:order) } + + describe "#create" do + context "when success" do + it "sets order as processed" do + post :create, params: {order_id: order.uuid} + + expect(response).to be_successful + expect(order.reload).to be_processed + end + end + + context "when failure" do + it "doesn't process the order" do + order.name = nil + expect(Order).to receive(:find_by!).and_return(order) + + post :create, params: {order_id: order.uuid} + + expect(response).to have_http_status(:unprocessable_entity) + expect(order.reload).not_to be_processed + end + end + end +end diff --git a/voyage/spec/controllers/api/v1/orders_controller_spec.rb b/voyage/spec/controllers/api/v1/orders_controller_spec.rb new file mode 100644 index 0000000..eb753c4 --- /dev/null +++ b/voyage/spec/controllers/api/v1/orders_controller_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +describe Api::V1::OrdersController do + let_it_be(:order_1) { create(:order) } + let_it_be(:order_2) { create(:order) } + let_it_be(:order_3) { create(:order) } + let(:data) { response.parsed_body["data"] } + + describe "#index" do + it "returns orders" do + expect(get(:index)).to be_successful + expect(data.size).to eq 2 + expect(data[0]["id"]).to eq order_3.uuid + expect(data[1]["id"]).to eq order_2.uuid + end + + it "paginates orders" do + expect(get(:index, params: {page: 2})).to be_successful + expect(data.size).to eq 1 + expect(data[0]["id"]).to eq order_1.uuid + end + end + + describe "#show" do + it "returns order" do + expect(get(:show, params: {id: order_1.uuid})).to be_successful + expect(data["id"]).to eq order_1.uuid + end + end +end diff --git a/voyage/spec/controllers/welcome_controller_spec.rb b/voyage/spec/controllers/welcome_controller_spec.rb new file mode 100644 index 0000000..056391d --- /dev/null +++ b/voyage/spec/controllers/welcome_controller_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe WelcomeController do + describe "GET index" do + it do + get :index + expect(response).to have_http_status(:success) + end + end +end diff --git a/voyage/spec/decoders/order_decoder_spec.rb b/voyage/spec/decoders/order_decoder_spec.rb new file mode 100644 index 0000000..6d0b4df --- /dev/null +++ b/voyage/spec/decoders/order_decoder_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +describe OrderDecoder do + let(:decoder) { described_class.new(payload) } + let(:payload) { build(:order_inbox_item).payload } + + it "decodes a message" do + expect(decoder.decode.keys) + .to include(:id, :name, :qty, :description, :status, :price, :updated_at, :created_at) + end +end diff --git a/voyage/spec/factories/order_factory.rb b/voyage/spec/factories/order_factory.rb new file mode 100644 index 0000000..ef4c36c --- /dev/null +++ b/voyage/spec/factories/order_factory.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :order do + name { Faker::Beer.name } + qty { 1 } + price { 4.20 } + description { Faker::Beer.style } + status { :completed } + + trait :processed do + status { :processed } + end + end +end diff --git a/voyage/spec/factories/order_inbox_item_factory.rb b/voyage/spec/factories/order_inbox_item_factory.rb new file mode 100644 index 0000000..5be754f --- /dev/null +++ b/voyage/spec/factories/order_inbox_item_factory.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "../../pkg/client/quest/events/order_pb" + +FactoryBot.define do + factory :order_inbox_item do + order + payload { (+"\n\f29dSQcVvXe1r\x12)Racer 5 India Pale Ale, Bear Republic Bre\x18\x01 \x01*\x14European Amber Lager2\t\n\x03RUB\x10\x04\x18\x14:\f\b\xE7\xF8\x8C\xA3\x06\x10\xF0\xB4\xA5\xED\x01B\f\b\x85\xE0\x92\xA3\x06\x10\xC8\xB0\xB7\xB6\x01").force_encoding("ASCII-8BIT") } + bucket { 0 } + + trait :payload_with_empty_name do + payload { (+"\n\f29dSQcVvXe1r\x18\x01 \x01*\x14European Amber Lager2\t\n\x03RUB\x10\x04\x18\x14:\f\b\xE7\xF8\x8C\xA3\x06\x10\xF0\xB4\xA5\xED\x01B\f\b\x85\xE0\x92\xA3\x06\x10\xC8\xB0\xB7\xB6\x01").force_encoding("ASCII-8BIT") } + end + end +end diff --git a/voyage/spec/interactors/inbox_importers/order_importer_spec.rb b/voyage/spec/interactors/inbox_importers/order_importer_spec.rb new file mode 100644 index 0000000..aa6bcfa --- /dev/null +++ b/voyage/spec/interactors/inbox_importers/order_importer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +describe InboxImporters::OrderImporter do + let!(:inbox_item) { create(:order_inbox_item) } + let(:service) { described_class.new(inbox_item, payload) } + let(:result) { service.call } + let(:payload) { inbox_item.payload } + + it "creates an order" do + expect { result }.to change(Order, :count).by(1) + expect(result).to be_success + end + + context "when payload invalid" do + let!(:inbox_item) { create(:order_inbox_item, :payload_with_empty_name) } + + it "returns failure" do + expect { result }.not_to change(Order, :count) + expect(result).to be_failure + end + end +end diff --git a/voyage/spec/interactors/orders/process_order_spec.rb b/voyage/spec/interactors/orders/process_order_spec.rb new file mode 100644 index 0000000..d9661af --- /dev/null +++ b/voyage/spec/interactors/orders/process_order_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe Orders::ProcessOrder do + let_it_be(:order, reload: true) { create(:order) } + + let(:serivce) { described_class.new(order) } + let(:result) { serivce.call } + + context "when success" do + it "sets order as processed" do + expect { result }.to change { order.reload.status }.from("completed").to("processed") + expect(result).to be_success + end + end + + context "when failure" do + it "doesn't process the order" do + order.name = nil + expect(result).to be_failure + expect(order.reload).not_to be_processed + end + end +end diff --git a/voyage/spec/models/order_inbox_item_spec.rb b/voyage/spec/models/order_inbox_item_spec.rb new file mode 100644 index 0000000..5cac6b9 --- /dev/null +++ b/voyage/spec/models/order_inbox_item_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +describe OrderInboxItem do + it "creates an inbox item" do + expect(create(:order_inbox_item)).to be_persisted + end +end diff --git a/voyage/spec/models/order_spec.rb b/voyage/spec/models/order_spec.rb new file mode 100644 index 0000000..311f638 --- /dev/null +++ b/voyage/spec/models/order_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Order do + let(:order) { build(:order) } + + it "creates an order" do + expect(order.save).to be true + end +end diff --git a/voyage/spec/rails_helper.rb b/voyage/spec/rails_helper.rb new file mode 100644 index 0000000..2fbc93a --- /dev/null +++ b/voyage/spec/rails_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] = "test" + +require "bundler/setup" + +begin + require File.expand_path("../../config/environment", __FILE__) +rescue => e + # Fail fast if application couldn't be loaded + $stdout.puts "Failed to load the app: #{e.message}\n#{e.backtrace.take(5).join("\n")}" + exit(1) +end + +require "rspec/rails" +require "faker" +require "test_prof/recipes/rspec/before_all" +require "test_prof/recipes/rspec/let_it_be" + +Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } + +Faker::Config.random = Random.new(0xCAFEBABE) + +RSpec.configure do |config| + # Add `travel_to` + config.include ActiveSupport::Testing::TimeHelpers + # Add `fixture_file_upload` + config.include ActionDispatch::TestProcess::FixtureFile + config.include Shoulda::Matchers::ActiveModel + config.include FactoryBot::Syntax::Methods + + config.fixture_path = Rails.root.join("spec/fixtures") + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! + + config.after do + Rails.cache.clear + end +end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/voyage/spec/serializers/order_inbox_item_serializer_spec.rb b/voyage/spec/serializers/order_inbox_item_serializer_spec.rb new file mode 100644 index 0000000..8ba109c --- /dev/null +++ b/voyage/spec/serializers/order_inbox_item_serializer_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +describe OrderInboxItemSerializer do + let_it_be(:inbox_item) { create(:order_inbox_item) } + + let(:order) { outbox_item.order } + let(:serializer) { described_class.new(inbox_item) } + let(:result) { serializer.serializable_hash } + let(:expected_attribute_names) do + [ + :event_key, + :bucket, + :status, + :options, + :errors_count, + :error_log, + :payload, + :processed_at, + :created_at, + :updated_at + ] + end + + it "serializes required attributes" do + expect(result[:data][:attributes]).to include(*expected_attribute_names) + expect(result[:data][:attributes][:payload]).to include(:id, :name, :qty, :price, :description) + end +end diff --git a/voyage/spec/serializers/order_serializer_spec.rb b/voyage/spec/serializers/order_serializer_spec.rb new file mode 100644 index 0000000..2b122ea --- /dev/null +++ b/voyage/spec/serializers/order_serializer_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe OrderSerializer do + let(:serializer) { described_class.new(order) } + let(:result) { serializer.serializable_hash } + let(:expected_attribute_names) do + [ + :name, + :qty, + :status, + :price, + :description, + :updated_at, + :created_at + ] + end + + let_it_be(:order) { create(:order) } + + it "serializes required attributes" do + expect(result[:data][:attributes]).to include(*expected_attribute_names) + end +end diff --git a/voyage/spec/serializers/validation_errors_serializer_spec.rb b/voyage/spec/serializers/validation_errors_serializer_spec.rb new file mode 100644 index 0000000..9a18f19 --- /dev/null +++ b/voyage/spec/serializers/validation_errors_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +describe ValidationErrorsSerializer do + let(:serializer) { described_class.new(errors, is_collection: false) } + let(:errors) { object.errors } + let(:result) { serializer.serializable_hash } + + describe "#full_messages" do + let(:object) { build(:order, name: nil, qty: nil) } + + it "renders" do + expect(object).not_to be_valid + expect(result[:data][:attributes][:full_messages]) + .to include("Name can't be blank", "Qty can't be blank") + end + end + + describe "#details" do + let(:object) { build(:order, name: nil, qty: nil) } + + it "renders" do + expect(object).not_to be_valid + expect(result[:data][:attributes][:details]) + .to eq(name: [{error: :blank}], qty: [{error: :blank}]) + end + end +end diff --git a/voyage/spec/spec_helper.rb b/voyage/spec/spec_helper.rb new file mode 100644 index 0000000..7452182 --- /dev/null +++ b/voyage/spec/spec_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rspec" +require "rspec_junit_formatter" +require "rspec/json_expectations" +require "shoulda-matchers" +require "yabeda" +require "yabeda/rspec" + +RSpec::Matchers.define_negated_matcher :not_change, :change +RSpec::Matchers.define_negated_matcher :not_include, :include + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = ".rspec_status" + config.run_all_when_everything_filtered = true + + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + config.order = :random + Kernel.srand config.seed +end diff --git a/voyage/tmp/.keep b/voyage/tmp/.keep new file mode 100644 index 0000000..e69de29 From a4eec757e3e23b34ff0f9ad12d03748e25e1b144 Mon Sep 17 00:00:00 2001 From: Misha Merkushin Date: Thu, 29 Feb 2024 22:54:32 +0300 Subject: [PATCH 2/3] fix: redis config --- quest/config/configs/redis_config.rb | 2 +- voyage/config/configs/redis_config.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quest/config/configs/redis_config.rb b/quest/config/configs/redis_config.rb index c18cf18..af8503f 100644 --- a/quest/config/configs/redis_config.rb +++ b/quest/config/configs/redis_config.rb @@ -11,7 +11,7 @@ class RedisConfig < ApplicationConfig required :url, :db, :db_sidekiq, :db_outbox, :db_cache def connection_uri(db_name = :db) - uri = URI(dsn) + uri = URI(url) uri.path = "/#{public_send(db_name)}" uri.to_s end diff --git a/voyage/config/configs/redis_config.rb b/voyage/config/configs/redis_config.rb index c18cf18..af8503f 100644 --- a/voyage/config/configs/redis_config.rb +++ b/voyage/config/configs/redis_config.rb @@ -11,7 +11,7 @@ class RedisConfig < ApplicationConfig required :url, :db, :db_sidekiq, :db_outbox, :db_cache def connection_uri(db_name = :db) - uri = URI(dsn) + uri = URI(url) uri.path = "/#{public_send(db_name)}" uri.to_s end From 7299e11921cc1b0ac559450e8a09e6e14ddc870b Mon Sep 17 00:00:00 2001 From: Misha Merkushin Date: Thu, 29 Feb 2024 23:01:01 +0300 Subject: [PATCH 3/3] docs: update readme --- README.md | 2 +- quest/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d5aac8c..c42ce3e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Outbox example apps -This is example applications which uses: +These are example applications that use: - [sbmt-outbox](https://github.com/SberMarket-Tech/sbmt-outbox) - [sbmt-kafka_producer](https://github.com/SberMarket-Tech/sbmt-kafka_producer) - [sbmt-kafka_consumer](https://github.com/SberMarket-Tech/sbmt-kafka_consumer) diff --git a/quest/README.md b/quest/README.md index 1c2a4ad..83c0d5d 100644 --- a/quest/README.md +++ b/quest/README.md @@ -1,6 +1,6 @@ # Quest -This is an example application which uses: +These are example applications that use: - [sbmt-outbox](https://github.com/SberMarket-Tech/sbmt-outbox) - [sbmt-kafka_producer](https://github.com/SberMarket-Tech/sbmt-kafka_producer)