├── .browserslistrc ├── .circleci └── config.yml ├── .deploy ├── bin │ ├── build │ │ └── nginx_image.sh │ ├── deploy │ │ ├── base.sh │ │ ├── production.sh │ │ └── staging.sh │ └── variables.sh └── nginx │ ├── Dockerfile │ ├── configs │ ├── nginx.conf │ └── templates │ │ └── default.conf │ └── docker-entrypoint.sh ├── .docker ├── development │ ├── Dockerfile │ ├── docker-compose.yml │ └── entrypoint.sh ├── production │ ├── Dockerfile │ └── entrypoint.sh └── staging │ ├── Dockerfile │ └── entrypoint.sh ├── .dockerignore ├── .fasterer.yml ├── .github ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── .gitignore ├── .reek.yml ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── .simplecov ├── .terraform ├── .gitignore ├── modules │ ├── ecs_cluster │ │ ├── autoscaling_group.tf │ │ ├── autoscaling_policy.tf │ │ ├── launch_template.tf │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── templates │ │ │ └── user_data.sh │ │ └── variables.tf │ ├── global │ │ ├── iam.tf │ │ ├── outputs.tf │ │ ├── variables.tf │ │ └── vpc.tf │ ├── main │ │ ├── cloudwatch.tf │ │ ├── ecr.tf │ │ ├── keypair.tf │ │ ├── outputs.tf │ │ ├── provider.tf │ │ ├── s3.tf │ │ ├── variables.tf │ │ └── versions.tf │ └── variables │ │ ├── outputs.tf │ │ └── variables.tf ├── production │ ├── acm.tf │ ├── alb.tf │ ├── backend.tf │ ├── ecs_application.tf │ ├── ecs_worker.tf │ ├── elasticache.tf │ ├── main.tf │ ├── rds.tf │ ├── sg.tf │ ├── ssh_keys │ │ └── boilerplate-api-production.pub │ ├── templates │ │ ├── container_definitions.json │ │ └── worker_definitions.json │ └── variables.tf └── staging │ ├── alb.tf │ ├── backend.tf │ ├── ecs.tf │ ├── main.tf │ ├── sg.tf │ ├── ssh_keys │ └── boilerplate-api-staging.pub │ ├── ssl_certificates │ └── .keep │ ├── templates │ └── container_definitions.json │ └── variables.tf ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── app ├── admin │ ├── admin_users.rb │ └── dashboard.rb ├── assets │ └── config │ │ └── manifest.js ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── concepts │ ├── api │ │ └── v1 │ │ │ ├── lib │ │ │ ├── contract │ │ │ │ ├── filtering_pre_validation.rb │ │ │ │ ├── filtering_validation.rb │ │ │ │ ├── inclusion_validation.rb │ │ │ │ ├── pagination_validation.rb │ │ │ │ ├── sorting_pre_validation.rb │ │ │ │ └── sorting_validation.rb │ │ │ ├── operation │ │ │ │ ├── filtering.rb │ │ │ │ ├── inclusion.rb │ │ │ │ ├── pagination.rb │ │ │ │ ├── perform_filtering.rb │ │ │ │ ├── perform_ordering.rb │ │ │ │ └── sorting.rb │ │ │ ├── serializer │ │ │ │ └── account.rb │ │ │ └── service │ │ │ │ └── json_api.rb │ │ │ └── users │ │ │ ├── account │ │ │ └── profiles │ │ │ │ ├── operation │ │ │ │ └── show.rb │ │ │ │ └── serializer │ │ │ │ └── show.rb │ │ │ ├── lib │ │ │ ├── contract │ │ │ │ └── decrypt_email_token_validation.rb │ │ │ ├── operation │ │ │ │ ├── check_email_token_redis_equality.rb │ │ │ │ └── decrypt_email_token.rb │ │ │ └── service │ │ │ │ ├── email_token.rb │ │ │ │ ├── redis_adapter.rb │ │ │ │ ├── session_token.rb │ │ │ │ └── token_namespace.rb │ │ │ ├── registrations │ │ │ ├── contract │ │ │ │ └── create.rb │ │ │ ├── operation │ │ │ │ └── create.rb │ │ │ └── worker │ │ │ │ └── email_confirmation.rb │ │ │ ├── reset_passwords │ │ │ ├── contract │ │ │ │ ├── create.rb │ │ │ │ └── update.rb │ │ │ ├── operation │ │ │ │ ├── create.rb │ │ │ │ ├── show.rb │ │ │ │ └── update.rb │ │ │ └── worker │ │ │ │ └── email_reset_password_url.rb │ │ │ ├── sessions │ │ │ ├── contract │ │ │ │ └── create.rb │ │ │ ├── operation │ │ │ │ ├── create.rb │ │ │ │ └── destroy.rb │ │ │ └── refreshes │ │ │ │ └── operation │ │ │ │ └── create.rb │ │ │ └── verifications │ │ │ └── operation │ │ │ └── show.rb │ ├── application_contract.rb │ ├── application_decorator.rb │ ├── application_operation.rb │ ├── application_serializer.rb │ └── application_worker.rb ├── controllers │ ├── api │ │ └── v1 │ │ │ └── users │ │ │ ├── account │ │ │ └── profiles_controller.rb │ │ │ ├── registrations_controller.rb │ │ │ ├── reset_passwords_controller.rb │ │ │ ├── session │ │ │ └── refreshes_controller.rb │ │ │ ├── sessions_controller.rb │ │ │ └── verifications_controller.rb │ ├── api_controller.rb │ ├── application_controller.rb │ ├── authorized_api_controller.rb │ └── concerns │ │ ├── authentication.rb │ │ └── default_endpoint.rb ├── jobs │ └── application_job.rb ├── mailers │ ├── application_mailer.rb │ └── user_mailer.rb ├── models │ ├── account.rb │ ├── admin_user.rb │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ └── user.rb ├── uploaders │ └── application_uploader.rb ├── views │ ├── layouts │ │ ├── mailer.html.erb │ │ └── mailer.text.erb │ └── user_mailer │ │ ├── confirmation.html.erb │ │ ├── confirmation.text.erb │ │ ├── reset_password.html.erb │ │ ├── reset_password.text.erb │ │ ├── reset_password_successful.html.erb │ │ ├── reset_password_successful.text.erb │ │ ├── verification_successful.html.erb │ │ └── verification_successful.text.erb ├── webpacker │ ├── packs │ │ ├── active_admin.js │ │ └── active_admin │ │ │ └── print.scss │ └── stylesheets │ │ └── active_admin.scss └── workers │ ├── application_worker.rb │ └── sentry_worker.rb ├── babel.config.js ├── bin ├── bundle ├── docker ├── rails ├── rake ├── setup ├── spring ├── webpack └── webpack-dev-server ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials │ ├── credentials.yml │ ├── development.yml.enc │ ├── production.yml.enc │ ├── staging.yml.enc │ └── test.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ ├── staging.rb │ └── test.rb ├── i18n-tasks.yml ├── initializers │ ├── active_admin.rb │ ├── application_controller_renderer.rb │ ├── backtrace_silencers.rb │ ├── constants │ │ ├── shared.rb │ │ └── tokens_namespace.rb │ ├── cors.rb │ ├── devise.rb │ ├── dry_validation.rb │ ├── filter_parameter_logging.rb │ ├── generators.rb │ ├── inflections.rb │ ├── json_api │ │ ├── filtering.rb │ │ ├── pagination.rb │ │ └── sorting.rb │ ├── jwt_sessions.rb │ ├── mime_types.rb │ ├── oj.rb │ ├── pagy.rb │ ├── ransack.rb │ ├── redis.rb │ ├── reform.rb │ ├── rswag_api.rb │ ├── rswag_ui.rb │ ├── sentry.rb │ ├── shrine.rb │ ├── sidekiq.rb │ ├── trailblazer_macro.rb │ ├── wrap_parameters.rb │ └── zeitwerk.rb ├── locales │ ├── devise.en.yml │ ├── en.errors.yml │ └── en.user_mailer.yml ├── puma.rb ├── rails_best_practices.yml ├── routes.rb ├── sidekiq.yml ├── spring.rb ├── storage.yml ├── webpack │ ├── development.js │ ├── environment.js │ ├── plugins │ │ └── jquery.js │ ├── production.js │ └── test.js └── webpacker.yml ├── db ├── migrate │ ├── 20190813110526_enable_uuid.rb │ ├── 20190813110527_create_users.rb │ ├── 20190813124813_create_accounts.rb │ └── 20200831072010_devise_create_admin_users.rb ├── schema.rb └── seeds.rb ├── dip.yml ├── lefthook.yml ├── lib ├── container.rb ├── macro │ ├── add_contract_error.rb │ ├── assign.rb │ ├── decorate.rb │ ├── inject.rb │ ├── links_builder.rb │ ├── model.rb │ ├── model_remove.rb │ ├── policy.rb │ ├── renderer.rb │ ├── schema.rb │ └── semantic.rb ├── service │ ├── json_api │ │ ├── base_error_serializer.rb │ │ ├── error_data_structure_parser.rb │ │ ├── hash_error_serializer.rb │ │ ├── paginator.rb │ │ ├── resource_error_serializer.rb │ │ ├── resource_serializer.rb │ │ └── uri_query_error_serializer.rb │ └── pagy.rb ├── tasks │ ├── factory_bot_linter.rake │ └── generate_api_documentation.rake └── types │ └── json_api.rb ├── log └── .keep ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── robots.txt ├── spec ├── concepts │ ├── api │ │ └── v1 │ │ │ ├── lib │ │ │ ├── operation │ │ │ │ ├── filtering_spec.rb │ │ │ │ ├── inclusion_spec.rb │ │ │ │ ├── pagination_spec.rb │ │ │ │ ├── perform_filtering_spec.rb │ │ │ │ ├── perform_ordering_spec.rb │ │ │ │ └── sorting_spec.rb │ │ │ └── service │ │ │ │ └── json_api_spec.rb │ │ │ └── users │ │ │ ├── account │ │ │ └── profiles │ │ │ │ └── show_spec.rb │ │ │ ├── lib │ │ │ ├── operation │ │ │ │ ├── check_email_token_redis_equality_spec.rb │ │ │ │ └── decrypt_email_token_spec.rb │ │ │ └── service │ │ │ │ ├── email_token_spec.rb │ │ │ │ ├── redis_adapter_spec.rb │ │ │ │ ├── session_token_spec.rb │ │ │ │ └── token_namespace_spec.rb │ │ │ ├── registrations │ │ │ ├── create_spec.rb │ │ │ └── worker │ │ │ │ └── email_confirmation_spec.rb │ │ │ ├── reset_passwords │ │ │ ├── create_spec.rb │ │ │ ├── show_spec.rb │ │ │ ├── update_spec.rb │ │ │ └── worker │ │ │ │ └── email_reset_password_url_spec.rb │ │ │ ├── sessions │ │ │ ├── create_spec.rb │ │ │ ├── destroy_spec.rb │ │ │ └── refreshes │ │ │ │ └── create_spec.rb │ │ │ └── verifications │ │ │ └── show_spec.rb │ ├── application_contract_spec.rb │ ├── application_decorator_spec.rb │ ├── application_operation_spec.rb │ ├── application_serializer_spec.rb │ └── application_worker_spec.rb ├── constants │ ├── shared_spec.rb │ └── token_namespace_spec.rb ├── controllers │ ├── api │ │ └── v1 │ │ │ └── users │ │ │ └── sessions_controller_spec.rb │ ├── api_controller_spec.rb │ └── application_controller_spec.rb ├── factories │ ├── accounts.rb │ ├── admin_users.rb │ └── users.rb ├── features │ └── admin │ │ ├── admin_users │ │ ├── edit_spec.rb │ │ ├── index_spec.rb │ │ └── show_spec.rb │ │ ├── check_root_path_spec.rb │ │ ├── sign_in_spec.rb │ │ └── sign_out_spec.rb ├── jobs │ └── application_job_spec.rb ├── lib │ ├── macro │ │ ├── add_contract_error_spec.rb │ │ ├── assign_spec.rb │ │ ├── decorate_spec.rb │ │ ├── inject_spec.rb │ │ ├── links_builder_spec.rb │ │ ├── model_remove_spec.rb │ │ ├── model_spec.rb │ │ ├── policy_spec.rb │ │ ├── renderer_spec.rb │ │ ├── schema_spec.rb │ │ └── semantic_spec.rb │ ├── service │ │ ├── json_api │ │ │ ├── hash_error_serializer_spec.rb │ │ │ ├── paginator_spec.rb │ │ │ ├── resource_error_serializer_spec.rb │ │ │ ├── resource_serializer_spec.rb │ │ │ └── uri_query_error_serializer_spec.rb │ │ └── pagy_spec.rb │ └── types │ │ └── json_api_spec.rb ├── mailers │ ├── application_mailer_spec.rb │ └── user_mailer_spec.rb ├── models │ ├── account_spec.rb │ ├── application_record_spec.rb │ └── user_spec.rb ├── rails_helper.rb ├── requests │ └── api │ │ └── v1 │ │ └── users │ │ ├── account │ │ └── profiles_spec.rb │ │ ├── registrations_spec.rb │ │ ├── reset_passwords_spec.rb │ │ ├── session │ │ └── refreshes_spec.rb │ │ ├── sessions_spec.rb │ │ └── verifications_spec.rb ├── spec_helper.rb ├── support │ ├── config │ │ ├── capybara.rb │ │ ├── ffaker.rb │ │ ├── json_matchers.rb │ │ ├── n_plus_one_control.rb │ │ ├── shoulda_matchers.rb │ │ └── sidekiq.rb │ ├── helpers │ │ ├── operation_helpers.rb │ │ ├── request_helpers.rb │ │ ├── root_helpers.rb │ │ └── root_helpers_spec.rb │ ├── matchers │ │ ├── macro_id_with.rb │ │ └── not_change.rb │ ├── schemas │ │ ├── errors.json │ │ └── v1 │ │ │ └── users │ │ │ ├── account │ │ │ └── profile │ │ │ │ └── show.json │ │ │ ├── registration │ │ │ └── create.json │ │ │ └── session │ │ │ ├── create.json │ │ │ └── refresh │ │ │ └── create.json │ └── shared_examples │ │ ├── operation │ │ ├── has_email_token_decryption_errors.rb │ │ ├── has_email_token_equality_errors.rb │ │ ├── has_validation_errors.rb │ │ └── nested_inclusion_errors.rb │ │ ├── request │ │ ├── renders_bad_request_errors.rb │ │ ├── renders_unauthenticated_errors.rb │ │ ├── renders_unprocessable_entity_errors.rb │ │ ├── renders_uri_query_errors.rb │ │ └── returns_not_found_status.rb │ │ └── service │ │ └── json_api │ │ └── error_data_structure.rb ├── swagger_helper.rb ├── uploaders │ └── application_uploader_spec.rb └── workers │ ├── application_worker_spec.rb │ └── sentry_worker_spec.rb ├── storage └── .keep ├── swagger └── v1 │ └── swagger.yaml ├── tmp └── .keep ├── vendor └── .keep └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /.deploy/bin/build/nginx_image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | source ".deploy/bin/variables.sh" 6 | 7 | while [ $# -gt 0 ] 8 | do 9 | key="$1" 10 | 11 | case $key in 12 | --running-tag) 13 | RUNNING_TAG="$2" 14 | shift 15 | shift 16 | ;; 17 | *) 18 | echo "Unknown option $1\n" 19 | shift 20 | shift 21 | esac 22 | done 23 | 24 | IMAGE_NAME="boilerplate-api/$RUNNING_TAG/web-server" 25 | REPO="$ECR_ID.dkr.ecr.$REGION.amazonaws.com/$IMAGE_NAME" 26 | 27 | HASH_TAG="$(git rev-parse --short HEAD)" 28 | 29 | RUNNING_IMAGE=$REPO:$RUNNING_TAG 30 | CURRENT_IMAGE=$IMAGE_NAME:$HASH_TAG 31 | 32 | $(aws ecr get-login --region $REGION --no-include-email) 33 | 34 | docker build --cache-from=$RUNNING_IMAGE -t $CURRENT_IMAGE ./.deploy/nginx 35 | 36 | docker tag $CURRENT_IMAGE $REPO:$HASH_TAG 37 | docker tag $CURRENT_IMAGE $RUNNING_IMAGE 38 | 39 | docker push $REPO:$HASH_TAG 40 | docker push $RUNNING_IMAGE 41 | -------------------------------------------------------------------------------- /.deploy/bin/deploy/production.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ".deploy/bin/variables.sh" 4 | source ".deploy/bin/deploy/base.sh" 5 | 6 | IMAGE_NAME="boilerplate-api/production/server-app" 7 | 8 | deploy \ 9 | --region "$REGION" \ 10 | --aws-access-key "$AWS_ACCESS_KEY_ID" \ 11 | --aws-secret-key "$AWS_SECRET_ACCESS_KEY" \ 12 | --image-name "$IMAGE_NAME" \ 13 | --repo "$ECR_ID.dkr.ecr.$REGION.amazonaws.com/$IMAGE_NAME" \ 14 | --cluster "boilerplate-api-production" \ 15 | --service "boilerplate-api-production" \ 16 | --running-tag "production" \ 17 | --docker_file ".docker/production/Dockerfile" 18 | 19 | deploy \ 20 | --region "$REGION" \ 21 | --aws-access-key "$AWS_ACCESS_KEY_ID" \ 22 | --aws-secret-key "$AWS_SECRET_ACCESS_KEY" \ 23 | --image-name "$IMAGE_NAME" \ 24 | --repo "$ECR_ID.dkr.ecr.$REGION.amazonaws.com/$IMAGE_NAME" \ 25 | --cluster "boilerplate-api-production" \ 26 | --service "boilerplate-api-production-worker" \ 27 | --running-tag "production" \ 28 | --docker_file ".docker/production/Dockerfile" \ 29 | --skip-build true 30 | -------------------------------------------------------------------------------- /.deploy/bin/deploy/staging.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ".deploy/bin/variables.sh" 4 | source ".deploy/bin/deploy/base.sh" 5 | 6 | IMAGE_NAME="boilerplate-api/staging/server-app" 7 | 8 | deploy \ 9 | --region "$REGION" \ 10 | --aws-access-key "$AWS_ACCESS_KEY_ID" \ 11 | --aws-secret-key "$AWS_SECRET_ACCESS_KEY" \ 12 | --image-name "$IMAGE_NAME" \ 13 | --repo "$ECR_ID.dkr.ecr.$REGION.amazonaws.com/$IMAGE_NAME" \ 14 | --cluster "boilerplate-api-staging" \ 15 | --service "boilerplate-api-staging" \ 16 | --running-tag "staging" \ 17 | --docker_file ".docker/staging/Dockerfile" 18 | -------------------------------------------------------------------------------- /.deploy/bin/variables.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export REGION="" 4 | export ECR_ID="" 5 | -------------------------------------------------------------------------------- /.deploy/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.17-alpine 2 | 3 | ENV USER=nginx 4 | 5 | RUN chown -R $USER:$USER /etc/nginx/conf.d 6 | 7 | USER $USER 8 | 9 | COPY --chown=$USER configs/nginx.conf /etc/nginx/nginx.conf 10 | COPY --chown=$USER configs/templates/default.conf /etc/nginx/conf.d/templates/default.conf 11 | 12 | COPY --chown=$USER docker-entrypoint.sh /usr/local/bin/ 13 | ENTRYPOINT ["docker-entrypoint.sh"] 14 | 15 | CMD ["nginx", "-g", "daemon off;"] 16 | -------------------------------------------------------------------------------- /.deploy/nginx/configs/nginx.conf: -------------------------------------------------------------------------------- 1 | pid /tmp/nginx.pid; 2 | 3 | worker_processes auto; 4 | worker_rlimit_nofile 4096; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | http { 11 | client_body_temp_path /tmp/client_temp; 12 | 13 | proxy_temp_path /tmp/proxy_temp_path; 14 | fastcgi_temp_path /tmp/fastcgi_temp; 15 | uwsgi_temp_path /tmp/uwsgi_temp; 16 | scgi_temp_path /tmp/scgi_temp; 17 | 18 | include /etc/nginx/mime.types; 19 | default_type application/octet-stream; 20 | 21 | server_tokens off; 22 | 23 | add_header X-Frame-Options SAMEORIGIN; 24 | add_header X-Content-Type-Options nosniff; 25 | add_header X-XSS-Protection "1; mode=block"; 26 | 27 | server_names_hash_bucket_size 64; 28 | server_names_hash_max_size 512; 29 | 30 | sendfile on; 31 | tcp_nopush on; 32 | 33 | types_hash_max_size 2048; 34 | 35 | gzip on; 36 | gzip_disable "msie6"; 37 | 38 | include /etc/nginx/conf.d/default.conf; 39 | } 40 | -------------------------------------------------------------------------------- /.deploy/nginx/configs/templates/default.conf: -------------------------------------------------------------------------------- 1 | upstream app { 2 | server app:3000; 3 | } 4 | 5 | server { 6 | listen 8080 deferred; 7 | 8 | client_max_body_size 15000M; 9 | 10 | keepalive_timeout 10; 11 | 12 | root /home/www/boilerplate-api/public/; 13 | 14 | # Redirect from www to non-www ---------------------------------------------- 15 | if ($host ~* "^www\.(.*)") { 16 | return 301 http://$1$request_uri; 17 | } 18 | # --------------------------------------------------------------------------- 19 | 20 | location ^~ /assets/ { 21 | gzip_static on; 22 | expires max; 23 | add_header Cache-Control public; 24 | } 25 | 26 | location ^~ \/admin\/settings(.*) { 27 | proxy_connect_timeout 10800; 28 | proxy_send_timeout 10800; 29 | proxy_read_timeout 10800; 30 | send_timeout 10800; 31 | keepalive_timeout 10800; 32 | } 33 | 34 | location / { 35 | try_files $uri @app_proxy; 36 | } 37 | 38 | location ^~ \/admin\/videos(.*) { 39 | proxy_connect_timeout 10800; 40 | proxy_send_timeout 10800; 41 | proxy_read_timeout 10800; 42 | send_timeout 10800; 43 | keepalive_timeout 10800; 44 | } 45 | 46 | location @app_proxy { 47 | proxy_redirect off; 48 | 49 | proxy_set_header Client-Ip $remote_addr; 50 | proxy_set_header Host $host; 51 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 52 | proxy_set_header X-Forwarded-Proto $scheme; 53 | proxy_set_header X-Forwarded-Ssl off; 54 | proxy_set_header X-Forwarded-Port $server_port; 55 | proxy_set_header X-Forwarded-Host $host; 56 | 57 | proxy_pass http://app; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.deploy/nginx/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | envsubst '$STATIC_PATH' < /etc/nginx/conf.d/templates/default.conf > /etc/nginx/conf.d/default.conf 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /.docker/development/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7.2-alpine3.12 as Builder 2 | 3 | RUN apk --no-cache add bash git openssh httpie libxml2-dev libxslt-dev postgresql-dev \ 4 | tzdata npm nodejs imagemagick yarn make cmake g++ postgresql-client nano 5 | 6 | ENV APP_USER app 7 | ENV APP_USER_HOME /home/$APP_USER 8 | ENV APP_HOME /home/www/boilerplate-api 9 | 10 | RUN adduser -D -h $APP_USER_HOME $APP_USER 11 | 12 | WORKDIR $APP_HOME 13 | 14 | USER $APP_USER 15 | 16 | COPY Gemfile* package.json yarn.lock .ruby-version ./ 17 | 18 | RUN gem i bundler -v $(tail -1 Gemfile.lock | tr -d ' ') && \ 19 | bundle install || bundle check \ 20 | && rm -rf /usr/local/bundle/cache/*.gem \ 21 | && find /usr/local/bundle/gems/ -name "*.c" -delete \ 22 | && find /usr/local/bundle/gems/ -name "*.o" -delete 23 | 24 | USER root 25 | 26 | RUN yarn install --check-files 27 | 28 | COPY . . 29 | 30 | RUN RAILS_ENV=development rake assets:precompile 31 | 32 | RUN rm -rf node_modules spec tmp/cache 33 | 34 | FROM ruby:2.7.2-alpine3.12 35 | 36 | RUN apk --no-cache add bash openssh httpie libxml2-dev libxslt-dev postgresql-dev \ 37 | tzdata nodejs imagemagick postgresql-client nano 38 | 39 | ENV APP_USER app 40 | ENV APP_HOME /home/www/boilerplate-api 41 | 42 | RUN addgroup -g 1000 -S $APP_USER && adduser -u 1000 -S $APP_USER -G $APP_USER 43 | 44 | USER $APP_USER 45 | 46 | COPY --from=Builder /usr/local/bundle/ /usr/local/bundle/ 47 | COPY --from=Builder --chown=1000:1000 $APP_HOME $APP_HOME 48 | 49 | WORKDIR $APP_HOME 50 | 51 | USER $APP_USER 52 | -------------------------------------------------------------------------------- /.docker/development/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | volumes: 4 | postgres: 5 | in_memory_store: 6 | 7 | services: 8 | db: 9 | image: postgres:11-alpine 10 | ports: 11 | - 5432:5432 12 | environment: 13 | POSTGRES_USER: postgres 14 | POSTGRES_PASSWORD: postgres 15 | volumes: 16 | - postgres:/var/lib/postgresql/data 17 | healthcheck: 18 | test: ["CMD", "pg_isready", "-U", "postgres"] 19 | 20 | in_memory_store: 21 | image: redis:5-alpine 22 | ports: 23 | - 6379:6379 24 | volumes: 25 | - in_memory_store:/var/lib/redis/data 26 | healthcheck: 27 | test: ["CMD", "redis-cli", "-h", "localhost", "ping"] 28 | 29 | server_app: &server_app 30 | build: 31 | context: ../../ 32 | dockerfile: ".docker/development/Dockerfile" 33 | command: bundle exec rails server -b 0.0.0.0 34 | entrypoint: .docker/development/entrypoint.sh 35 | volumes: 36 | - ../..:/home/www/boilerplate-api 37 | tty: true 38 | stdin_open: true 39 | environment: &server_app_env 40 | DB_HOST: db 41 | DB_PORT: 5432 42 | DB_USERNAME: postgres 43 | DB_PASSWORD: postgres 44 | REDIS_DB: redis://in_memory_store:6379 45 | depends_on: 46 | - db 47 | ports: 48 | - 3000:3000 49 | healthcheck: 50 | test: ["CMD", "http", "GET", "localhost:3000"] 51 | 52 | server_worker_app: 53 | <<: *server_app 54 | command: bundle exec sidekiq -C config/sidekiq.yml 55 | entrypoint: "" 56 | ports: [] 57 | depends_on: 58 | - db 59 | - in_memory_store 60 | healthcheck: 61 | test: ["CMD-SHELL", "ps ax | grep -v grep | grep sidekiq || exit 1"] 62 | -------------------------------------------------------------------------------- /.docker/development/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Interpreter identifier 3 | 4 | # Exit on fail 5 | set -e 6 | 7 | rm -f $APP_HOME/tmp/pids/server.pid 8 | rm -f $APP_HOME/tmp/pids/sidekiq.pid 9 | 10 | bundle exec rails db:create 11 | bundle exec rails db:migrate 12 | 13 | exec "$@" 14 | -------------------------------------------------------------------------------- /.docker/production/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7.2-alpine3.12 as Builder 2 | 3 | RUN apk add --update --no-cache \ 4 | bash \ 5 | git \ 6 | libxml2-dev \ 7 | libxslt-dev \ 8 | postgresql-dev \ 9 | tzdata \ 10 | nodejs \ 11 | yarn \ 12 | cmake \ 13 | build-base \ 14 | nano 15 | 16 | ENV APP_USER app 17 | ENV APP_USER_HOME /home/$APP_USER 18 | ENV APP_HOME /home/www/boilerplate-api 19 | 20 | RUN adduser -D -h $APP_USER_HOME $APP_USER 21 | 22 | WORKDIR $APP_HOME 23 | 24 | USER $APP_USER 25 | 26 | COPY Gemfile* package.json yarn.lock .ruby-version ./ 27 | 28 | RUN gem i bundler -v $(tail -1 Gemfile.lock | tr -d ' ') && \ 29 | bundle install --without development test -j $(nproc) --retry 5 || bundle check \ 30 | && rm -rf /usr/local/bundle/cache/*.gem \ 31 | && find /usr/local/bundle/gems/ -name "*.c" -delete \ 32 | && find /usr/local/bundle/gems/ -name "*.o" -delete 33 | 34 | USER root 35 | 36 | RUN yarn install --check-files 37 | 38 | COPY . . 39 | 40 | RUN RAILS_ENV=development rails assets:precompile 41 | 42 | RUN rm -rf node_modules spec tmp/cache 43 | 44 | FROM ruby:2.7.2-alpine3.12 45 | 46 | RUN apk add --update --no-cache \ 47 | bash \ 48 | build-base \ 49 | libxml2-dev \ 50 | libxslt-dev \ 51 | tzdata \ 52 | curl \ 53 | git \ 54 | nodejs \ 55 | imagemagick \ 56 | postgresql-client \ 57 | nano 58 | 59 | ENV APP_USER app 60 | ENV APP_HOME /home/www/boilerplate-api 61 | 62 | RUN addgroup -g 1000 -S $APP_USER && adduser -u 1000 -S $APP_USER -G $APP_USER 63 | 64 | USER $APP_USER 65 | 66 | COPY --from=Builder /usr/local/bundle/ /usr/local/bundle/ 67 | COPY --from=Builder --chown=1000:1000 $APP_HOME $APP_HOME 68 | 69 | RUN chmod -R 777 $APP_HOME/.docker/production/entrypoint.sh 70 | RUN chmod +x $APP_HOME/.docker/production/entrypoint.sh 71 | 72 | WORKDIR $APP_HOME 73 | 74 | USER $APP_USER 75 | -------------------------------------------------------------------------------- /.docker/production/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -f $APP_HOME/tmp/pids/server.pid 6 | rm -f $APP_HOME/tmp/pids/sidekiq.pid 7 | 8 | bundle exec rails db:create 9 | bundle exec rails db:migrate 10 | 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /.docker/staging/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7.2-alpine3.12 as Builder 2 | 3 | RUN apk add --update --no-cache \ 4 | bash \ 5 | git \ 6 | libxml2-dev \ 7 | libxslt-dev \ 8 | postgresql-dev \ 9 | tzdata \ 10 | nodejs \ 11 | yarn \ 12 | cmake \ 13 | build-base \ 14 | nano 15 | 16 | ENV APP_USER app 17 | ENV APP_USER_HOME /home/$APP_USER 18 | ENV APP_HOME /home/www/boilerplate-api 19 | 20 | RUN adduser -D -h $APP_USER_HOME $APP_USER 21 | 22 | WORKDIR $APP_HOME 23 | 24 | USER $APP_USER 25 | 26 | COPY Gemfile* package.json yarn.lock .ruby-version ./ 27 | 28 | RUN gem i bundler -v $(tail -1 Gemfile.lock | tr -d ' ') && \ 29 | bundle install --without development test -j $(nproc) --retry 5 || bundle check \ 30 | && rm -rf /usr/local/bundle/cache/*.gem \ 31 | && find /usr/local/bundle/gems/ -name "*.c" -delete \ 32 | && find /usr/local/bundle/gems/ -name "*.o" -delete 33 | 34 | USER root 35 | 36 | RUN yarn install --check-files 37 | 38 | COPY . . 39 | 40 | RUN RAILS_ENV=development rails assets:precompile 41 | 42 | RUN rm -rf node_modules spec tmp/cache 43 | 44 | FROM ruby:2.7.2-alpine3.12 45 | 46 | RUN apk add --update --no-cache \ 47 | bash \ 48 | build-base \ 49 | libxml2-dev \ 50 | libxslt-dev \ 51 | tzdata \ 52 | curl \ 53 | git \ 54 | nodejs \ 55 | imagemagick \ 56 | postgresql-client \ 57 | nano 58 | 59 | ENV APP_USER app 60 | ENV APP_HOME /home/www/boilerplate-api 61 | 62 | RUN addgroup -g 1000 -S $APP_USER && adduser -u 1000 -S $APP_USER -G $APP_USER 63 | 64 | USER $APP_USER 65 | 66 | COPY --from=Builder /usr/local/bundle/ /usr/local/bundle/ 67 | COPY --from=Builder --chown=1000:1000 $APP_HOME $APP_HOME 68 | 69 | RUN chmod -R 777 $APP_HOME/.docker/staging/entrypoint.sh 70 | RUN chmod +x $APP_HOME/.docker/staging/entrypoint.sh 71 | 72 | WORKDIR $APP_HOME 73 | 74 | USER $APP_USER 75 | -------------------------------------------------------------------------------- /.docker/staging/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -f $APP_HOME/tmp/pids/server.pid 6 | rm -f $APP_HOME/tmp/pids/sidekiq.pid 7 | 8 | bundle exec rails db:create 9 | bundle exec rails db:migrate 10 | 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | /log/\* 3 | /tmp/\* 4 | !/log/.keep 5 | !/tmp/.keep 6 | !/tmp/pids/.keep 7 | /vendor/bundle 8 | /public/assets 9 | /config/master.key 10 | /config/credentials.local.yml 11 | /deploy/configs 12 | /.bundle 13 | /venv 14 | /node_modules 15 | /.terraform 16 | /coverage 17 | /.circleci 18 | /.github 19 | -------------------------------------------------------------------------------- /.fasterer.yml: -------------------------------------------------------------------------------- 1 | exclude_paths: 2 | - 'vendor/**/*.rb' 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | [Replace this text by the issue key](replace_this_text_by_the_issue_link) 2 | 3 | ### Description 4 | 5 | Replace this text with a summary of the changes in your merge request. 6 | 7 | ### Before submitting the merge request make sure the following are checked 8 | 9 | - [ ] Followed the style guidelines of this project 10 | - [ ] Performed a self-review of own code 11 | - [ ] Wrote the tests that prove that fix is effective/that feature works 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "bundler" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | labels: 9 | - "dependency" 10 | - "need review" 11 | rebase-strategy: "disabled" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore secrets 11 | /config/application.yml 12 | /config/cable.yml 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/pids/.keep 19 | !/tmp/.keep 20 | .rvmrc 21 | 22 | .DS_Store 23 | .byebug_history 24 | 25 | # Ignore spec files 26 | /spec/examples.txt 27 | 28 | # Ignore test coverage info 29 | /coverage/* 30 | 31 | # Ignore RubyMine IDE configs 32 | /.idea 33 | 34 | /vendor/bundle 35 | 36 | /public/packs 37 | /public/packs-test 38 | /public/assets 39 | /node_modules 40 | /yarn-error.log 41 | yarn-debug.log* 42 | .yarn-integrity 43 | 44 | .history 45 | 46 | /config/credentials/development.key 47 | /config/credentials/test.key 48 | /config/credentials/staging.key 49 | /config/credentials/production.key 50 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require rails_helper 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | boilerplate-rails-api 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.2 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov-lcov' 4 | 5 | SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true 6 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new( 7 | [ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | SimpleCov::Formatter::LcovFormatter 10 | ] 11 | ) 12 | 13 | SimpleCov.minimum_coverage(100) 14 | 15 | if ARGV.grep(/spec.\w+/).empty? 16 | SimpleCov.start 'rails' do 17 | add_filter(%r{^/spec/}) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.terraform/.gitignore: -------------------------------------------------------------------------------- 1 | *.terraform 2 | *.tfstate 3 | *.tfstate.backup 4 | *.tfvars 5 | */ssh_keys/* 6 | !*/ssh_keys/*.pub 7 | */ssl_certificates/* 8 | !*/ssl_certificates/.keep 9 | -------------------------------------------------------------------------------- /.terraform/modules/ecs_cluster/autoscaling_group.tf: -------------------------------------------------------------------------------- 1 | resource "aws_autoscaling_group" "cluster-instance" { 2 | name = var.name 3 | vpc_zone_identifier = var.subnet_ids 4 | desired_capacity = var.number_of_instances 5 | min_size = var.min_number_of_instances 6 | max_size = var.max_number_of_instances 7 | health_check_grace_period = 30 8 | 9 | launch_template { 10 | id = aws_launch_template.this.id 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.terraform/modules/ecs_cluster/launch_template.tf: -------------------------------------------------------------------------------- 1 | data "aws_ami" "ecs_optimized" { 2 | owners = ["amazon"] 3 | most_recent = true 4 | 5 | filter { 6 | name = "name" 7 | values = ["amzn2-ami-ecs-hvm-2.0.20191114-x86_64-ebs"] 8 | } 9 | } 10 | 11 | data "template_file" "user_data" { 12 | template = file("${path.module}/templates/user_data.sh") 13 | 14 | vars = { 15 | cluster_name = aws_ecs_cluster.this.name 16 | swap_size = var.swap_size 17 | } 18 | } 19 | 20 | resource "aws_launch_template" "this" { 21 | name = var.name 22 | instance_type = var.instance_type 23 | key_name = var.key_pair.key_name 24 | vpc_security_group_ids = [var.cluster_instance_sg.id] 25 | image_id = data.aws_ami.ecs_optimized.id 26 | user_data = base64encode(data.template_file.user_data.rendered) 27 | 28 | iam_instance_profile { 29 | name = var.ecs_instance_iam_instance_profile.name 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.terraform/modules/ecs_cluster/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "this" { 2 | name = var.name 3 | } 4 | -------------------------------------------------------------------------------- /.terraform/modules/ecs_cluster/outputs.tf: -------------------------------------------------------------------------------- 1 | output "cluster" { 2 | value = aws_ecs_cluster.this 3 | } 4 | 5 | output "autoscaling_group_name" { 6 | value = aws_autoscaling_group.cluster-instance.name 7 | } 8 | -------------------------------------------------------------------------------- /.terraform/modules/ecs_cluster/templates/user_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Register 4 | echo "ECS_CLUSTER=${cluster_name}" >> /etc/ecs/ecs.config 5 | 6 | # Swap tuning 7 | dd if=/dev/zero of=/swapfile bs=1M count=${swap_size} 8 | chmod 600 /swapfile 9 | mkswap /swapfile 10 | swapon /swapfile 11 | swapon -s 12 | echo "/swapfile swap swap defaults 0 0" >> /etc/fstab 13 | -------------------------------------------------------------------------------- /.terraform/modules/ecs_cluster/variables.tf: -------------------------------------------------------------------------------- 1 | variable "name" { 2 | description = "ECS cluster name" 3 | } 4 | 5 | variable "instance_type" { 6 | description = "EC2 instance type to run in ECS cluster" 7 | } 8 | 9 | variable "swap_size" { 10 | description = "Size of the swap file" 11 | } 12 | 13 | variable "number_of_instances" { 14 | description = "Number of instances in ECS cluster" 15 | } 16 | 17 | variable "min_number_of_instances" { 18 | description = "Minimum number of instances in ECS cluster" 19 | } 20 | 21 | variable "max_number_of_instances" { 22 | description = "Maximum number of instances in ECS cluster" 23 | } 24 | 25 | variable "key_pair" { 26 | description = "SSH key pair" 27 | } 28 | 29 | variable "cluster_instance_sg" { 30 | description = "Security group for ECS cluster instance" 31 | } 32 | 33 | variable "ecs_instance_iam_instance_profile" { 34 | description = "IAM instance profile with ECS instance role" 35 | } 36 | 37 | variable "subnet_ids" { 38 | description = "Subnet ids to launch ECS cluster instances in" 39 | } 40 | -------------------------------------------------------------------------------- /.terraform/modules/global/iam.tf: -------------------------------------------------------------------------------- 1 | # ECS instance role 2 | 3 | data "aws_iam_policy_document" "ecs_instance" { 4 | statement { 5 | actions = ["sts:AssumeRole"] 6 | 7 | principals { 8 | type = "Service" 9 | identifiers = ["ec2.amazonaws.com"] 10 | } 11 | } 12 | } 13 | 14 | resource "aws_iam_role" "ecs_instance" { 15 | name = "${var.project_name_env}-ecs-cloud-instance" 16 | assume_role_policy = data.aws_iam_policy_document.ecs_instance.json 17 | } 18 | 19 | resource "aws_iam_role_policy_attachment" "ecs_instance" { 20 | role = aws_iam_role.ecs_instance.name 21 | policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" 22 | } 23 | 24 | resource "aws_iam_instance_profile" "ecs_instance" { 25 | name = "${var.project_name_env}-ecs-cloud-instance" 26 | role = aws_iam_role.ecs_instance.name 27 | } 28 | -------------------------------------------------------------------------------- /.terraform/modules/global/outputs.tf: -------------------------------------------------------------------------------- 1 | output "ecs_instance_iam_instance_profile" { 2 | value = aws_iam_instance_profile.ecs_instance 3 | } 4 | 5 | output "ecs_instance_iam_role" { 6 | value = aws_iam_role.ecs_instance 7 | } 8 | 9 | output "default_subnet_ids" { 10 | value = data.aws_subnet_ids.default.ids 11 | } 12 | 13 | output "default_vpc_id" { 14 | value = data.aws_vpc.default.id 15 | } 16 | -------------------------------------------------------------------------------- /.terraform/modules/global/variables.tf: -------------------------------------------------------------------------------- 1 | variable "project_name_env" { 2 | description = "Project name with environment" 3 | } 4 | -------------------------------------------------------------------------------- /.terraform/modules/global/vpc.tf: -------------------------------------------------------------------------------- 1 | data "aws_vpc" "default" { 2 | default = true 3 | } 4 | 5 | data "aws_subnet_ids" "default" { 6 | vpc_id = data.aws_vpc.default.id 7 | } 8 | -------------------------------------------------------------------------------- /.terraform/modules/main/cloudwatch.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "this" { 2 | name = var.project_name_env 3 | retention_in_days = var.log_retention_in_days 4 | } 5 | -------------------------------------------------------------------------------- /.terraform/modules/main/ecr.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecr_repository" "web_server" { 2 | name = "${var.project_name}/${var.environment}/web-server" 3 | } 4 | 5 | resource "aws_ecr_repository" "app" { 6 | name = "${var.project_name}/${var.environment}/server-app" 7 | } 8 | -------------------------------------------------------------------------------- /.terraform/modules/main/keypair.tf: -------------------------------------------------------------------------------- 1 | resource "aws_key_pair" "this" { 2 | key_name = var.project_name_env 3 | public_key = file("${path.root}/ssh_keys/${var.project_name_env}.pub") 4 | } 5 | -------------------------------------------------------------------------------- /.terraform/modules/main/outputs.tf: -------------------------------------------------------------------------------- 1 | output "ecr_repositories" { 2 | value = { 3 | web_server = aws_ecr_repository.web_server 4 | app = aws_ecr_repository.app 5 | } 6 | } 7 | 8 | output "aws_key_pair" { 9 | value = aws_key_pair.this 10 | } 11 | 12 | output "aws_cloudwatch_log_group" { 13 | value = aws_cloudwatch_log_group.this 14 | } 15 | 16 | output "bucket_name" { 17 | value = aws_s3_bucket.assets.bucket 18 | } 19 | -------------------------------------------------------------------------------- /.terraform/modules/main/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | version = "~> 2.34" 3 | region = var.region 4 | } 5 | 6 | provider "template" { 7 | version = "~> 2.1" 8 | } 9 | -------------------------------------------------------------------------------- /.terraform/modules/main/s3.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "assets" { 2 | bucket = "${var.project_name_env}-assets" 3 | } 4 | 5 | # S3 user 6 | 7 | resource "aws_iam_user" "s3_user" { 8 | name = "${var.project_name_env}-s3-bucket-user" 9 | } 10 | 11 | data "aws_iam_policy_document" "s3_user" { 12 | statement { 13 | actions = [ 14 | "s3:ListBucket", 15 | "s3:GetBucketLocation", 16 | "s3:ListBucketMultipartUploads" 17 | ] 18 | resources = [ 19 | "arn:aws:s3:::${aws_s3_bucket.assets.bucket}" 20 | ] 21 | } 22 | 23 | statement { 24 | actions = [ 25 | "s3:PutObject", 26 | "s3:GetObject", 27 | "s3:DeleteObject", 28 | "s3:ListMultipartUploadParts", 29 | "s3:AbortMultipartUpload" 30 | ] 31 | resources = [ 32 | "arn:aws:s3:::${aws_s3_bucket.assets.bucket}/*" 33 | ] 34 | } 35 | } 36 | 37 | resource "aws_iam_policy" "s3_user" { 38 | name = "${var.project_name_env}-s3-bucket-user" 39 | policy = data.aws_iam_policy_document.s3_user.json 40 | } 41 | 42 | resource "aws_iam_user_policy_attachment" "s3_user" { 43 | user = aws_iam_user.s3_user.name 44 | policy_arn = aws_iam_policy.s3_user.arn 45 | } 46 | -------------------------------------------------------------------------------- /.terraform/modules/main/variables.tf: -------------------------------------------------------------------------------- 1 | variable "region" { 2 | description = "AWS Region" 3 | } 4 | 5 | variable "project_name" { 6 | description = "Project name that will be visible in AWS resources" 7 | } 8 | 9 | variable "environment" { 10 | description = "Application environment" 11 | } 12 | 13 | variable "project_name_env" { 14 | description = "Project name with environment that will be visible in AWS resources names" 15 | } 16 | 17 | variable "log_retention_in_days" { 18 | description = "CloudWatch Logs retention period in days" 19 | } 20 | -------------------------------------------------------------------------------- /.terraform/modules/main/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12.17" 3 | } 4 | -------------------------------------------------------------------------------- /.terraform/production/acm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_acm_certificate" "this" { 2 | domain_name = module.variables.server_name 3 | validation_method = "DNS" 4 | 5 | tags = { 6 | Environment = "production" 7 | } 8 | 9 | lifecycle { 10 | create_before_destroy = true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.terraform/production/alb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lb" "this" { 2 | name = module.variables.project_name_env 3 | subnets = module.global.default_subnet_ids 4 | load_balancer_type = "application" 5 | security_groups = [aws_security_group.load_balancer.id] 6 | enable_deletion_protection = true 7 | } 8 | 9 | resource "aws_lb_listener" "http" { 10 | load_balancer_arn = aws_lb.this.arn 11 | port = "80" 12 | protocol = "HTTP" 13 | 14 | 15 | default_action { 16 | type = "forward" 17 | target_group_arn = aws_lb_target_group.this.arn 18 | } 19 | } 20 | 21 | resource "aws_lb_target_group" "this" { 22 | name = aws_lb.this.name 23 | port = "8080" 24 | protocol = "HTTP" 25 | vpc_id = module.global.default_vpc_id 26 | deregistration_delay = 30 27 | 28 | health_check { 29 | path = "/health_check" 30 | port = "80" 31 | protocol = "HTTP" 32 | timeout = 5 33 | interval = 30 34 | unhealthy_threshold = 2 35 | healthy_threshold = 2 36 | matcher = "200" 37 | } 38 | 39 | stickiness { 40 | type = "lb_cookie" 41 | enabled = true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.terraform/production/backend.tf: -------------------------------------------------------------------------------- 1 | # Backend can't use variables, values have to be hardcoded 2 | 3 | terraform { 4 | backend "remote" { 5 | organization = "boilerplate-api" 6 | 7 | workspaces { 8 | name = "production" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.terraform/production/ecs_application.tf: -------------------------------------------------------------------------------- 1 | # Task definition 2 | 3 | data "template_file" "container_definitions" { 4 | template = file("${path.module}/templates/container_definitions.json") 5 | 6 | vars = { 7 | project_name = module.variables.project_name 8 | environment = module.variables.environment 9 | server_name = module.variables.server_name 10 | app_port = module.variables.app_port 11 | 12 | web_server_ecr_repo = module.main.ecr_repositories.web_server.repository_url 13 | app_ecr_repo = module.main.ecr_repositories.app.repository_url 14 | 15 | log_group = module.main.aws_cloudwatch_log_group.name 16 | log_region = module.variables.region 17 | 18 | db_username = var.db_username 19 | db_password = var.db_password 20 | } 21 | } 22 | 23 | resource "aws_ecs_task_definition" "this" { 24 | family = module.variables.project_name_env 25 | network_mode = "bridge" 26 | cpu = module.variables.task_cpu 27 | memory = module.variables.task_memory 28 | 29 | container_definitions = data.template_file.container_definitions.rendered 30 | 31 | volume { 32 | name = "public" 33 | } 34 | } 35 | 36 | # Service 37 | 38 | resource "aws_ecs_service" "this" { 39 | name = module.variables.project_name_env 40 | cluster = module.ecs_cluster.cluster.id 41 | task_definition = aws_ecs_task_definition.this.arn 42 | desired_count = module.variables.min_task_count_application 43 | deployment_minimum_healthy_percent = module.variables.deployment_minimum_healthy_percent_application 44 | deployment_maximum_percent = module.variables.deployment_maximum_percent 45 | 46 | load_balancer { 47 | container_name = "web-server" 48 | container_port = 8080 49 | target_group_arn = aws_lb_target_group.this.arn 50 | } 51 | 52 | depends_on = [ 53 | module.global.ecs_instance_iam_role 54 | ] 55 | 56 | lifecycle { 57 | ignore_changes = [desired_count] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.terraform/production/ecs_worker.tf: -------------------------------------------------------------------------------- 1 | # Task definition 2 | 3 | data "template_file" "worker_definitions" { 4 | template = file("${path.module}/templates/worker_definitions.json") 5 | 6 | vars = { 7 | project_name = module.variables.project_name 8 | environment = module.variables.environment 9 | 10 | app_ecr_repo = module.main.ecr_repositories.app.repository_url 11 | 12 | log_group = module.main.aws_cloudwatch_log_group.name 13 | log_region = module.variables.region 14 | } 15 | } 16 | 17 | resource "aws_ecs_task_definition" "worker_task_definition" { 18 | family = "${module.variables.project_name_env}-worker" 19 | network_mode = "bridge" 20 | cpu = module.variables.task_cpu 21 | memory = module.variables.task_memory 22 | 23 | volume { 24 | name = "public" 25 | } 26 | 27 | container_definitions = data.template_file.worker_definitions.rendered 28 | } 29 | 30 | # Service 31 | 32 | resource "aws_ecs_service" "worker-service" { 33 | name = "${module.variables.project_name_env}-worker" 34 | cluster = module.ecs_cluster.cluster.id 35 | task_definition = aws_ecs_task_definition.worker_task_definition.arn 36 | desired_count = module.variables.min_task_count_worker 37 | deployment_minimum_healthy_percent = module.variables.deployment_minimum_healthy_percent_worker 38 | deployment_maximum_percent = module.variables.deployment_maximum_percent 39 | 40 | depends_on = [ 41 | module.global.ecs_instance_iam_role 42 | ] 43 | 44 | lifecycle { 45 | ignore_changes = [desired_count] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.terraform/production/elasticache.tf: -------------------------------------------------------------------------------- 1 | resource "aws_elasticache_cluster" "this" { 2 | cluster_id = module.variables.project_name_env 3 | engine = module.variables.elasticache_engine 4 | engine_version = module.variables.elasticache_engine_version 5 | node_type = module.variables.elasticache_node_type 6 | num_cache_nodes = 1 7 | security_group_ids = [aws_security_group.redis_server.id] 8 | } 9 | -------------------------------------------------------------------------------- /.terraform/production/main.tf: -------------------------------------------------------------------------------- 1 | module "variables" { 2 | source = "../modules/variables" 3 | environment = "production" 4 | } 5 | 6 | module "main" { 7 | source = "../modules/main" 8 | 9 | region = module.variables.region 10 | project_name = module.variables.project_name 11 | environment = module.variables.environment 12 | project_name_env = module.variables.project_name_env 13 | 14 | log_retention_in_days = module.variables.log_retention_in_days 15 | } 16 | 17 | module "global" { 18 | source = "../modules/global" 19 | project_name_env = module.variables.project_name_env 20 | } 21 | 22 | module "ecs_cluster" { 23 | source = "../modules/ecs_cluster" 24 | 25 | name = module.variables.project_name_env 26 | 27 | instance_type = module.variables.instance_type 28 | swap_size = module.variables.swap_size 29 | 30 | number_of_instances = module.variables.min_task_count_cluster 31 | min_number_of_instances = module.variables.min_task_count_cluster 32 | max_number_of_instances = module.variables.max_task_count_cluster 33 | 34 | key_pair = module.main.aws_key_pair 35 | subnet_ids = module.global.default_subnet_ids 36 | cluster_instance_sg = aws_security_group.cluster_instance 37 | ecs_instance_iam_instance_profile = module.global.ecs_instance_iam_instance_profile 38 | } 39 | -------------------------------------------------------------------------------- /.terraform/production/rds.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | db_identifier = module.variables.project_name_env 3 | } 4 | 5 | resource "aws_db_instance" "this" { 6 | identifier = local.db_identifier 7 | name = replace(local.db_identifier, "-", "_") 8 | engine = module.variables.rds_engine 9 | engine_version = module.variables.rds_engine_version 10 | instance_class = module.variables.rds_instance_class 11 | allocated_storage = module.variables.rds_allocated_storage 12 | storage_type = "gp2" 13 | username = replace(var.db_username, "-", "") 14 | password = var.db_password 15 | backup_retention_period = module.variables.rds_backup_retention_period 16 | performance_insights_enabled = true 17 | deletion_protection = true 18 | multi_az = false 19 | vpc_security_group_ids = [aws_security_group.db_server.id] 20 | } 21 | -------------------------------------------------------------------------------- /.terraform/production/sg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "load_balancer" { 2 | name = "${module.variables.project_name_env}-load-balancer" 3 | 4 | ingress { 5 | from_port = 80 6 | to_port = 80 7 | protocol = "tcp" 8 | cidr_blocks = ["0.0.0.0/0"] 9 | } 10 | 11 | ingress { 12 | from_port = 443 13 | to_port = 443 14 | protocol = "tcp" 15 | cidr_blocks = ["0.0.0.0/0"] 16 | } 17 | 18 | egress { 19 | from_port = 0 20 | to_port = 0 21 | protocol = -1 22 | cidr_blocks = ["0.0.0.0/0"] 23 | } 24 | } 25 | 26 | resource "aws_security_group" "db_server" { 27 | name = "${module.variables.project_name_env}-db-server" 28 | 29 | ingress { 30 | from_port = 5432 31 | to_port = 5432 32 | protocol = "tcp" 33 | security_groups = [aws_security_group.cluster_instance.id] 34 | } 35 | } 36 | 37 | resource "aws_security_group" "redis_server" { 38 | name = "${module.variables.project_name_env}-redis-server" 39 | 40 | ingress { 41 | from_port = 6379 42 | to_port = 6379 43 | protocol = "tcp" 44 | security_groups = [aws_security_group.cluster_instance.id] 45 | } 46 | } 47 | 48 | resource "aws_security_group" "cluster_instance" { 49 | name = "${module.variables.project_name_env}-cluster-instance" 50 | 51 | ingress { 52 | from_port = 80 53 | to_port = 80 54 | protocol = "tcp" 55 | cidr_blocks = ["0.0.0.0/0"] 56 | } 57 | 58 | ingress { 59 | from_port = 443 60 | to_port = 443 61 | protocol = "tcp" 62 | cidr_blocks = ["0.0.0.0/0"] 63 | } 64 | 65 | ingress { 66 | from_port = 22 67 | to_port = 22 68 | protocol = "tcp" 69 | cidr_blocks = ["0.0.0.0/0"] 70 | } 71 | 72 | egress { 73 | from_port = 0 74 | to_port = 0 75 | protocol = -1 76 | cidr_blocks = ["0.0.0.0/0"] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.terraform/production/ssh_keys/boilerplate-api-production.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNmOmLNxo8GCqxzsc0PBPLIOEy3z+c5XXUBSWLPFQUn2xsRr2WVjcGBsxy4wOLc45Sna+5WWV2w4ggihNx/sij7ytKjkSTjA2V4DhNucVT4zNDpdE88mJVyt1ETfOX3S5m3ii9+Vf2rU22jpr5OP23qBFPgUPFn8lXcHYami0rrC9G3Wqpz23ER9oj7XXV/efW5XgZZQTL8DyF4iY+pvBjtAMKw0ZdQljKrUXw0MM7o8opFCuFLoxWoPcRgJZjxzbNUBjnSqoImMf2vjPh5bjadDMtdB1v0BL3kKjEnxOIvOwr5blowiMoF97h0Cv9ZJjhYlWfcO0FEJZQevl+oM8j vanechka@ivanich 2 | -------------------------------------------------------------------------------- /.terraform/production/templates/worker_definitions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "worker", 4 | "image": "${app_ecr_repo}:${environment}", 5 | "command": ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"], 6 | "environment": [ 7 | { 8 | "name": "RAILS_ENV", 9 | "value": "${environment}" 10 | }, 11 | { 12 | "name": "RACK_ENV", 13 | "value": "${environment}" 14 | }, 15 | { 16 | "name": "RAILS_LOG_TO_STDOUT", 17 | "value": "true" 18 | } 19 | ], 20 | "mountPoints": [ 21 | { 22 | "containerPath": "/home/www/${project_name}/public", 23 | "sourceVolume": "public" 24 | } 25 | ], 26 | "portMappings": [], 27 | "healthCheck": { 28 | "command": [ 29 | "CMD-SHELL", 30 | "ps ax | grep -v grep | grep sidekiq || exit 1" 31 | ], 32 | "interval": 30, 33 | "retries": 3, 34 | "timeout": 5 35 | }, 36 | "logConfiguration": { 37 | "logDriver": "awslogs", 38 | "options": { 39 | "awslogs-group": "${log_group}", 40 | "awslogs-region": "${log_region}", 41 | "awslogs-stream-prefix": "${environment}" 42 | } 43 | } 44 | } 45 | ] 46 | -------------------------------------------------------------------------------- /.terraform/production/variables.tf: -------------------------------------------------------------------------------- 1 | variable "db_username" { 2 | description = "Database username" 3 | } 4 | 5 | variable "db_password" { 6 | description = "Database password" 7 | } 8 | -------------------------------------------------------------------------------- /.terraform/staging/backend.tf: -------------------------------------------------------------------------------- 1 | # Backend can't use variables, values have to be hardcoded 2 | 3 | terraform { 4 | backend "remote" { 5 | organization = "boilerplate-api" 6 | 7 | workspaces { 8 | name = "staging" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.terraform/staging/main.tf: -------------------------------------------------------------------------------- 1 | module "variables" { 2 | source = "../modules/variables" 3 | environment = "staging" 4 | } 5 | 6 | module "main" { 7 | source = "../modules/main" 8 | 9 | region = module.variables.region 10 | project_name = module.variables.project_name 11 | environment = module.variables.environment 12 | project_name_env = module.variables.project_name_env 13 | 14 | log_retention_in_days = module.variables.log_retention_in_days 15 | } 16 | 17 | module "global" { 18 | source = "../modules/global" 19 | project_name_env = module.variables.project_name_env 20 | } 21 | 22 | module "ecs_cluster" { 23 | source = "../modules/ecs_cluster" 24 | 25 | name = module.variables.project_name_env 26 | 27 | instance_type = module.variables.instance_type 28 | swap_size = module.variables.swap_size 29 | 30 | number_of_instances = module.variables.min_task_count_application 31 | min_number_of_instances = module.variables.min_task_count_application 32 | max_number_of_instances = module.variables.max_task_count_application 33 | 34 | key_pair = module.main.aws_key_pair 35 | subnet_ids = module.global.default_subnet_ids 36 | cluster_instance_sg = aws_security_group.cluster_instance 37 | ecs_instance_iam_instance_profile = module.global.ecs_instance_iam_instance_profile 38 | } 39 | -------------------------------------------------------------------------------- /.terraform/staging/sg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "load_balancer" { 2 | name = "${module.variables.project_name_env}-load-balancer" 3 | 4 | ingress { 5 | from_port = 80 6 | to_port = 80 7 | protocol = "tcp" 8 | cidr_blocks = ["0.0.0.0/0"] 9 | } 10 | 11 | ingress { 12 | from_port = 443 13 | to_port = 443 14 | protocol = "tcp" 15 | cidr_blocks = ["0.0.0.0/0"] 16 | } 17 | 18 | egress { 19 | from_port = 0 20 | to_port = 0 21 | protocol = -1 22 | cidr_blocks = ["0.0.0.0/0"] 23 | } 24 | } 25 | 26 | resource "aws_security_group" "cluster_instance" { 27 | name = "${module.variables.project_name_env}-cluster-instance" 28 | 29 | ingress { 30 | from_port = 80 31 | to_port = 80 32 | protocol = "tcp" 33 | cidr_blocks = ["0.0.0.0/0"] 34 | } 35 | 36 | ingress { 37 | from_port = 443 38 | to_port = 443 39 | protocol = "tcp" 40 | cidr_blocks = ["0.0.0.0/0"] 41 | } 42 | 43 | ingress { 44 | from_port = 22 45 | to_port = 22 46 | protocol = "tcp" 47 | cidr_blocks = ["0.0.0.0/0"] 48 | } 49 | 50 | egress { 51 | from_port = 0 52 | to_port = 0 53 | protocol = -1 54 | cidr_blocks = ["0.0.0.0/0"] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.terraform/staging/ssh_keys/boilerplate-api-staging.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNmOmLNxo8GCqxzsc0PBPLIOEy3z+c5XXUBSWLPFQUn2xsRr2WVjcGBsxy4wOLc45Sna+5WWV2w4ggihNx/sij7ytKjkSTjA2V4DhNucVT4zNDpdE88mJVyt1ETfOX3S5m3ii9+Vf2rU22jpr5OP23qBFPgUPFn8lXcHYami0rrC9G3Wqpz23ER9oj7XXV/efW5XgZZQTL8DyF4iY+pvBjtAMKw0ZdQljKrUXw0MM7o8opFCuFLoxWoPcRgJZjxzbNUBjnSqoImMf2vjPh5bjadDMtdB1v0BL3kKjEnxOIvOwr5blowiMoF97h0Cv9ZJjhYlWfcO0FEJZQevl+oM8j vanechka@ivanich 2 | -------------------------------------------------------------------------------- /.terraform/staging/ssl_certificates/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/.terraform/staging/ssl_certificates/.keep -------------------------------------------------------------------------------- /.terraform/staging/variables.tf: -------------------------------------------------------------------------------- 1 | variable "db_username" { 2 | description = "Database username" 3 | } 4 | 5 | variable "db_password" { 6 | description = "Database password" 7 | } 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app/admin/admin_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveAdmin.register AdminUser do 4 | permit_params :email, :password, :password_confirmation 5 | 6 | index do 7 | selectable_column 8 | id_column 9 | column :email 10 | column :current_sign_in_at 11 | column :sign_in_count 12 | column :created_at 13 | actions 14 | end 15 | 16 | filter :email 17 | filter :current_sign_in_at 18 | filter :sign_in_count 19 | filter :created_at 20 | 21 | form do |f| 22 | f.inputs do 23 | f.input :email 24 | f.input :password 25 | f.input :password_confirmation 26 | end 27 | f.actions 28 | end 29 | 30 | show do 31 | attributes_table do 32 | row :id 33 | row :created_at 34 | row :email 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/admin/dashboard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveAdmin.register_page 'Dashboard' do 4 | menu priority: 1, label: proc { I18n.t('active_admin.dashboard') } 5 | 6 | content title: proc { I18n.t('active_admin.dashboard') } do 7 | div class: 'blank_slate_container', id: 'dashboard_default_message' do 8 | span class: 'blank_slate' do 9 | span I18n.t('active_admin.dashboard_welcome.welcome') 10 | small I18n.t('active_admin.dashboard_welcome.call_to_action') 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/app/assets/config/manifest.js -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # module ApplicationCable 4 | # class Channel < ActionCable::Channel::Base 5 | # end 6 | # end 7 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # module ApplicationCable 4 | # class Connection < ActionCable::Connection::Base 5 | # end 6 | # end 7 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/contract/filtering_pre_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Contract 4 | FilteringPreValidation = Dry::Validation.Schema do 5 | required(:filter).filled(:hash?) 6 | optional(:match).filled(:str?, included_in?: JsonApi::Filtering::OPERATORS) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/contract/filtering_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Contract 4 | FilteringValidation = Dry::Validation.Schema do 5 | configure do 6 | config.type_specs = true 7 | option :available_filtering_columns 8 | option :column_type_dict 9 | 10 | def filtering_column_valid?(filter) 11 | available_filtering_columns.include?(filter.column) 12 | end 13 | 14 | def filtering_predicate_valid?(filter) 15 | column = column_type_dict[filter.column] 16 | JsonApi::Filtering::PREDICATES[column].include?(filter.predicate) 17 | end 18 | 19 | def filtering_value_valid?(filter) 20 | column = column_type_dict[filter.column] 21 | Types::JsonApi::TypeByColumn.call(column).try(filter.value).success? 22 | end 23 | 24 | def filters_uniq?(filters) 25 | filters = filters.map(&:column) 26 | filters.eql?(filters.uniq) 27 | end 28 | end 29 | 30 | required(:filter, Types::JsonApi::Filter).filled(:array?, :filters_uniq?) do 31 | each(:filtering_column_valid?, :filtering_predicate_valid?, :filtering_value_valid?) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/contract/inclusion_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Contract 4 | InclusionValidation = Dry::Validation.Schema do 5 | configure do 6 | config.type_specs = true 7 | option :available_inclusion_options 8 | 9 | def inclusion_params_uniq?(jsonapi_inclusion_params) 10 | jsonapi_inclusion_params.eql?(jsonapi_inclusion_params.uniq) 11 | end 12 | 13 | def inclusion_params_valid?(jsonapi_inclusion_params) 14 | jsonapi_inclusion_params.difference(available_inclusion_options).empty? 15 | end 16 | end 17 | 18 | required(:include, Types::JsonApi::Include).filled(:inclusion_params_uniq?, :inclusion_params_valid?) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/contract/pagination_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Contract 4 | PaginationValidation = Dry::Validation.Form do 5 | optional(:page).maybe(:hash?) do 6 | schema do 7 | optional(:number).maybe(:int?, gteq?: JsonApi::Pagination::MINIMAL_VALUE) 8 | optional(:size).maybe(:int?, gteq?: JsonApi::Pagination::MINIMAL_VALUE) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/contract/sorting_pre_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Contract 4 | SortingPreValidation = Dry::Validation.Schema do 5 | required(:sort).filled(:str?) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/contract/sorting_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Contract 4 | SortingValidation = Dry::Validation.Schema do 5 | configure do 6 | config.type_specs = true 7 | option :available_sortable_columns 8 | 9 | def sort_params_uniq?(jsonapi_sort_params) 10 | jsonapi_sort_params = jsonapi_sort_params.map(&:column) 11 | jsonapi_sort_params.eql?(jsonapi_sort_params.uniq) 12 | end 13 | 14 | def sort_params_valid?(jsonapi_sort_params) 15 | jsonapi_sort_params.all? do |jsonapi_sort_parameter| 16 | available_sortable_columns.include?(jsonapi_sort_parameter.column) 17 | end 18 | end 19 | end 20 | 21 | required(:sort, Types::JsonApi::Sort) { sort_params_uniq? & sort_params_valid? } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/operation/filtering.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Operation 4 | class Filtering < ApplicationOperation 5 | step :filter_params_passed?, Output(:failure) => End(:success) 6 | step Macro::Contract::Schema(Api::V1::Lib::Contract::FilteringPreValidation, name: :uri_query) 7 | step Contract::Validate(name: :uri_query) 8 | step :matcher_options 9 | step :set_validation_dependencies 10 | step Macro::Contract::Schema( 11 | Api::V1::Lib::Contract::FilteringValidation, 12 | inject: %i[available_filtering_columns column_type_dict], 13 | name: :uri_query 14 | ) 15 | step Contract::Validate(name: :uri_query), id: :filtering_validation 16 | step :filter_options 17 | 18 | def filter_params_passed?(_ctx, params:, **) 19 | params[:filter] 20 | end 21 | 22 | def matcher_options(ctx, **) 23 | ctx[:matcher_options] = ctx['contract.uri_query'].match || JsonApi::Filtering::Operators::MATCH_ALL 24 | end 25 | 26 | def set_validation_dependencies(ctx, available_columns:, **) 27 | ctx[:available_filtering_columns] = available_columns.select(&:filterable).map(&:name).to_set 28 | ctx[:column_type_dict] = available_columns.map { |column| [column.name, column.type] }.to_h 29 | end 30 | 31 | def filter_options(ctx, **) 32 | ctx[:filter_options] = ctx['contract.uri_query'].filter.map(&:to_h) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/operation/inclusion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Operation 4 | class Inclusion < ApplicationOperation 5 | step :inclusion_query_param_passed?, Output(:failure) => End(:success) 6 | step Macro::Contract::Schema( 7 | Api::V1::Lib::Contract::InclusionValidation, 8 | name: :uri_query, 9 | inject: %i[available_inclusion_options] 10 | ) 11 | step Contract::Validate(name: :uri_query) 12 | step Macro::Assign(to: :inclusion_options, path: %w[contract.uri_query include]) 13 | 14 | def inclusion_query_param_passed?(_ctx, params:, **) 15 | params[:include] 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/operation/pagination.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Operation 4 | class Pagination < ApplicationOperation 5 | step Macro::Inject(paginator: 'services.pagy') 6 | step Macro::Contract::Schema(Api::V1::Lib::Contract::PaginationValidation, name: :uri_query) 7 | step Contract::Validate(name: :uri_query), fail_fast: true 8 | step :pagy 9 | step :valid_page? 10 | fail Macro::AddContractError(name: :uri_query, page: [:number, [I18n.t('errors.pagination_overflow')]]) 11 | 12 | def pagy(ctx, paginator:, model:, **) 13 | ctx[:pagy], ctx[:model] = 14 | paginator.call( 15 | model, 16 | page: ctx['contract.uri_query'].page.try(:number), 17 | items: ctx['contract.uri_query'].page.try(:size) 18 | ) 19 | end 20 | 21 | def valid_page?(_ctx, pagy:, **) 22 | !pagy.overflow? 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/operation/perform_filtering.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Operation 4 | class PerformFiltering < ApplicationOperation 5 | step :filter_options_passed?, Output(:failure) => End(:success) 6 | pass :build_filter_query 7 | pass :filter_relation 8 | 9 | def filter_options_passed?(ctx, **) 10 | ctx[:filter_options] 11 | end 12 | 13 | def build_filter_query(ctx, filter_options:, **) 14 | ctx[:filter_query] = filter_options.map do |option| 15 | value = option[:value] 16 | value = value.split(',') if value.is_a?(String) 17 | value = value.first if value.is_a?(Array) && value.one? 18 | 19 | { 20 | "#{option[:column]}_#{option[:predicate]}": value 21 | } 22 | end.inject(:merge) 23 | end 24 | 25 | def filter_relation(ctx, filter_query:, relation:, **) 26 | ctx[:relation] = relation.ransack(filter_query).result 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/operation/perform_ordering.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Operation 4 | class PerformOrdering < ApplicationOperation 5 | step :order_options_passed?, Output(:failure) => End(:success) 6 | pass :order_relation 7 | 8 | def order_options_passed?(ctx, **) 9 | ctx[:order_options] 10 | end 11 | 12 | def order_relation(ctx, order_options:, relation:, **) 13 | ctx[:relation] = relation.reorder(order_options) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/operation/sorting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Operation 4 | class Sorting < ApplicationOperation 5 | step :sort_params_passed?, Output(:failure) => End(:success) 6 | step Macro::Contract::Schema(Api::V1::Lib::Contract::SortingPreValidation, name: :uri_query) 7 | step Contract::Validate(name: :uri_query) 8 | step :set_validation_dependencies 9 | step Macro::Contract::Schema( 10 | Api::V1::Lib::Contract::SortingValidation, 11 | inject: %i[available_sortable_columns], 12 | name: :uri_query 13 | ) 14 | step Contract::Validate(name: :uri_query), id: :sorting_validation 15 | step :order_options 16 | 17 | def sort_params_passed?(_ctx, params:, **) 18 | params[:sort] 19 | end 20 | 21 | def set_validation_dependencies(ctx, available_columns:, **) 22 | ctx[:available_sortable_columns] = available_columns.select(&:sortable).map(&:name).to_set 23 | end 24 | 25 | def order_options(ctx, **) 26 | ctx[:order_options] = ctx['contract.uri_query'].sort.map do |jsonapi_sort_parameter| 27 | { jsonapi_sort_parameter.column.to_sym => jsonapi_sort_parameter.order.to_sym } 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/serializer/account.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Serializer 4 | class Account < ApplicationSerializer 5 | set_type :account 6 | attributes :email, :created_at 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/concepts/api/v1/lib/service/json_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Lib::Service::JsonApi 4 | Column = Struct.new(:name, :type, :sortable, :filterable, keyword_init: true) do 5 | def initialize(name:, type: :string, **args) 6 | super 7 | end 8 | end 9 | 10 | module ColumnsBuilder 11 | def self.call(*columns) 12 | columns.map { |column| Api::V1::Lib::Service::JsonApi::Column.new(column) } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/account/profiles/operation/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Account::Profiles::Operation 4 | class Show < ApplicationOperation 5 | step Macro::Assign(to: :model, path: %i[current_account user]) 6 | step Macro::Assign(to: :available_inclusion_options, value: %w[account]) 7 | step Subprocess(Api::V1::Lib::Operation::Inclusion) 8 | step Macro::Renderer(serializer: Api::V1::Users::Account::Profiles::Serializer::Show) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/account/profiles/serializer/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Account::Profiles::Serializer 4 | class Show < ApplicationSerializer 5 | set_type 'user-profile' 6 | attributes :name 7 | belongs_to :account, serializer: Api::V1::Lib::Serializer::Account 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/contract/decrypt_email_token_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Contract 4 | DecryptEmailTokenValidation = Dry::Validation.Schema do 5 | required(:email_token).filled(:str?) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/operation/check_email_token_redis_equality.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Operation 4 | class CheckEmailTokenRedisEquality < ApplicationOperation 5 | step Macro::Inject(redis: 'adapters.redis') 6 | step :tokens_eql? 7 | fail Macro::AddContractError(base: 'errors.email_token.already_used') 8 | 9 | def tokens_eql?(_ctx, redis:, email_token:, **) 10 | email_token.eql?(redis.find_token(email_token)) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/operation/decrypt_email_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Operation 4 | class DecryptEmailToken < ApplicationOperation 5 | step Macro::Inject(jwt: 'services.email_token') 6 | step Macro::Contract::Schema(Api::V1::Users::Lib::Contract::DecryptEmailTokenValidation) 7 | step Contract::Validate(), fail_fast: true 8 | step Macro::Assign(to: :email_token, path: %w[contract.default email_token]) 9 | step :set_payload 10 | fail Macro::AddContractError(base: 'errors.verification.invalid_email_token'), fail_fast: true 11 | step :set_model 12 | fail Macro::Semantic(failure: :not_found) 13 | 14 | def set_payload(ctx, jwt:, email_token:, **) 15 | ctx[:payload] = jwt.read(email_token) 16 | end 17 | 18 | def set_model(ctx, payload:, **) 19 | ctx[:model] = Account.find_by(id: payload[:account_id]) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/service/email_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Service 4 | class EmailToken 5 | ERROR_MESSAGE = 'Secret key is not assigned' 6 | TOKEN_LIFETIME = 24.hours 7 | 8 | class << self 9 | def create(payload, exp = TOKEN_LIFETIME.from_now.to_i) 10 | check_secret_key 11 | payload[:exp] = exp 12 | JWT.encode(payload, Constants::Shared::HMAC_SECRET) 13 | end 14 | 15 | def read(token) 16 | check_secret_key 17 | body = JWT.decode(token, Constants::Shared::HMAC_SECRET).first rescue false 18 | return body unless body 19 | 20 | HashWithIndifferentAccess.new(body) 21 | end 22 | 23 | private 24 | 25 | def check_secret_key 26 | raise ERROR_MESSAGE unless Constants::Shared::HMAC_SECRET 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/service/redis_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Service 4 | class RedisAdapter 5 | attr_reader :storage, :token_name 6 | 7 | class << self 8 | def push_token(token) 9 | new(token).push 10 | end 11 | 12 | def find_token(token) 13 | new(token).find 14 | end 15 | 16 | def delete_token(token) 17 | new(token).delete 18 | end 19 | end 20 | 21 | def initialize(token) 22 | @storage = Redis.current 23 | @token = token 24 | payload = Api::V1::Users::Lib::Service::EmailToken.read(token) || {} 25 | @token_name = Api::V1::Users::Lib::Service::TokenNamespace.call(payload[:namespace], payload[:account_id]) 26 | @token_exp = payload[:exp] 27 | end 28 | 29 | def push 30 | storage.setex(token_name, @token_exp, @token) if @token_exp 31 | end 32 | 33 | def find 34 | storage.get(token_name) 35 | end 36 | 37 | def delete 38 | storage.del(token_name) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/service/session_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Service::SessionToken 4 | class << self 5 | def create(account_id:, namespace: nil) 6 | options = 7 | namespace ? { namespace: Api::V1::Users::Lib::Service::TokenNamespace.call(namespace, account_id) } : {} 8 | JWTSessions::Session.new( 9 | payload: { account_id: account_id, **options }, 10 | refresh_payload: { account_id: account_id, **options } 11 | ).login 12 | end 13 | 14 | def destroy(refresh_token:) 15 | JWTSessions::Session.new.flush_by_token(refresh_token) 16 | end 17 | 18 | def destroy_all(namespace:) 19 | JWTSessions::Session.new(namespace: namespace).flush_namespaced 20 | end 21 | 22 | def refresh(payload:, refresh_token:) 23 | session = JWTSessions::Session.new(payload: payload) 24 | session.refresh(refresh_token) do 25 | session.flush_by_token(refresh_token) 26 | return false 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/lib/service/token_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Lib::Service 4 | module TokenNamespace 5 | def self.call(namespace, entity_id) 6 | "#{namespace}-#{entity_id}" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/registrations/contract/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Registrations::Contract 4 | class Create < ApplicationContract 5 | property :email 6 | property :password 7 | property :password_confirmation, virtual: true 8 | 9 | validation :default do 10 | configure { config.namespace = :user_password } 11 | 12 | required(:email).filled( 13 | :str?, 14 | max_size?: 15 | Constants::Shared::EMAIL_MAX_LENGTH, 16 | format?: Constants::Shared::EMAIL_REGEX 17 | ) 18 | required(:password).filled(:str?) 19 | required(:password_confirmation).filled(:str?) 20 | 21 | required(:password).filled( 22 | :str?, 23 | min_size?: Constants::Shared::PASSWORD_MIN_SIZE, 24 | format?: Constants::Shared::PASSWORD_REGEX 25 | ).confirmation 26 | end 27 | 28 | validation :email, if: :default do 29 | configure do 30 | def email_uniq?(value) 31 | !Account.exists?(email: value) 32 | end 33 | end 34 | 35 | required(:email, &:email_uniq?) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/registrations/operation/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Registrations::Operation 4 | class Create < ApplicationOperation 5 | step Macro::Inject(jwt: 'services.email_token', worker: Api::V1::Users::Registrations::Worker::EmailConfirmation) 6 | step Model(Account, :new) 7 | step Contract::Build(constant: Api::V1::Users::Registrations::Contract::Create, name: 'registration') 8 | step Contract::Validate(name: 'registration') 9 | step Contract::Persist(name: 'registration') 10 | fail Macro::Semantic(failure: :bad_request) 11 | step :set_email_token 12 | step :send_confirmation_link 13 | step Macro::Renderer(serializer: Api::V1::Lib::Serializer::Account) 14 | pass Macro::Semantic(success: :created) 15 | 16 | def set_email_token(ctx, jwt:, model:, **) 17 | ctx[:email_token] = jwt.create(account_id: model.id) 18 | end 19 | 20 | def send_confirmation_link(_ctx, worker:, model:, email_token:, **) 21 | worker.perform_async( 22 | email: model.email, 23 | token: email_token, 24 | user_verification_path: Rails.application.config.user_verification_path 25 | ) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/registrations/worker/email_confirmation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Registrations::Worker 4 | class EmailConfirmation < ApplicationWorker 5 | def perform(email:, token:, user_verification_path:) 6 | UserMailer.confirmation(email, token, user_verification_path).deliver_now 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/reset_passwords/contract/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::ResetPasswords::Contract 4 | Create = Dry::Validation.Schema do 5 | required(:email).filled( 6 | :str?, 7 | max_size?: Constants::Shared::EMAIL_MAX_LENGTH, 8 | format?: Constants::Shared::EMAIL_REGEX 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/reset_passwords/contract/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::ResetPasswords::Contract 4 | class Update < ApplicationContract 5 | property :password 6 | property :password_confirmation, virtual: true 7 | 8 | validation do 9 | configure { config.namespace = :user_password } 10 | 11 | required(:password).filled(:str?) 12 | required(:password_confirmation).filled(:str?) 13 | 14 | required(:password).filled( 15 | :str?, 16 | min_size?: Constants::Shared::PASSWORD_MIN_SIZE, 17 | format?: Constants::Shared::PASSWORD_REGEX 18 | ).confirmation 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/reset_passwords/operation/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::ResetPasswords::Operation 4 | class Create < ApplicationOperation 5 | step Macro::Inject( 6 | jwt: 'services.email_token', 7 | redis: 'adapters.redis', 8 | worker: Api::V1::Users::ResetPasswords::Worker::EmailResetPasswordUrl 9 | ) 10 | step Macro::Contract::Schema(Api::V1::Users::ResetPasswords::Contract::Create) 11 | step Contract::Validate(), fail_fast: true 12 | step Model(Account, :find_by_email, :email) 13 | fail Macro::Semantic(failure: :not_found) 14 | fail Macro::AddContractError(email: 'errors.reset_password.email_not_found'), fail_fast: true 15 | step :set_email_token 16 | step :push_email_token_to_redis 17 | step :send_reset_password_url 18 | step Macro::Semantic(success: :accepted) 19 | 20 | def set_email_token(ctx, jwt:, model:, **) 21 | ctx[:email_token] = jwt.create(account_id: model.id, namespace: Constants::TokenNamespace::RESET_PASSWORD) 22 | end 23 | 24 | def push_email_token_to_redis(_ctx, redis:, email_token:, **) 25 | redis.push_token(email_token) 26 | end 27 | 28 | def send_reset_password_url(_ctx, worker:, model:, email_token:, **) 29 | worker.perform_async( 30 | email: model.email, 31 | token: email_token, 32 | user_reset_password_path: Rails.application.config.user_reset_password_path 33 | ) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/reset_passwords/operation/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::ResetPasswords::Operation 4 | class Show < ApplicationOperation 5 | step Subprocess(Api::V1::Users::Lib::Operation::DecryptEmailToken), fast_track: true 6 | step Subprocess(Api::V1::Users::Lib::Operation::CheckEmailTokenRedisEquality) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/reset_passwords/operation/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::ResetPasswords::Operation 4 | class Update < ApplicationOperation 5 | step Macro::Inject( 6 | redis: 'adapters.redis', 7 | session: 'services.session_token', 8 | namespace: 'services.token_namespace' 9 | ) 10 | step Subprocess(Api::V1::Users::Lib::Operation::DecryptEmailToken), fast_track: true 11 | step Subprocess(Api::V1::Users::Lib::Operation::CheckEmailTokenRedisEquality) 12 | step Contract::Build(constant: Api::V1::Users::ResetPasswords::Contract::Update) 13 | step Contract::Validate() 14 | step Contract::Persist() 15 | step :send_notification 16 | step :destroy_redis_email_token 17 | step :destroy_all_user_sessions 18 | 19 | def send_notification(_ctx, model:, **) 20 | UserMailer.reset_password_successful(email: model.email).deliver_later 21 | end 22 | 23 | def destroy_redis_email_token(_ctx, redis:, email_token:, **) 24 | redis.delete_token(email_token) 25 | end 26 | 27 | def destroy_all_user_sessions(_ctx, session:, namespace:, model:, **) 28 | session.destroy_all( 29 | namespace: namespace.call(Constants::TokenNamespace::SESSION, model.id) 30 | ) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/reset_passwords/worker/email_reset_password_url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::ResetPasswords::Worker 4 | class EmailResetPasswordUrl < ApplicationWorker 5 | def perform(email:, token:, user_reset_password_path:) 6 | UserMailer.reset_password(email, token, user_reset_password_path).deliver_now 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/sessions/contract/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Sessions::Contract 4 | Create = Dry::Validation.Schema do 5 | required(:email).filled(:str?) 6 | required(:password).filled(:str?) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/sessions/operation/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Sessions::Operation 4 | class Create < ApplicationOperation 5 | step Macro::Inject(session: 'services.session_token') 6 | step Macro::Contract::Schema(Api::V1::Users::Sessions::Contract::Create) 7 | step Contract::Validate(), fail_fast: true 8 | step Model(Account, :find_by_email, :email) 9 | step :authenticate 10 | fail Macro::Semantic(failure: :unauthorized) 11 | fail Macro::AddContractError(base: 'errors.session.wrong_credentials'), fail_fast: true 12 | step :set_user_tokens 13 | step Macro::Semantic(success: :created) 14 | step Macro::Renderer(serializer: Api::V1::Lib::Serializer::Account, meta: :tokens) 15 | 16 | def authenticate(ctx, model:, **) 17 | model.authenticate(ctx['contract.default'].password) 18 | end 19 | 20 | def set_user_tokens(ctx, session:, model:, **) 21 | ctx[:tokens] = session.create(account_id: model.id, namespace: Constants::TokenNamespace::SESSION) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/sessions/operation/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Sessions::Operation 4 | class Destroy < ApplicationOperation 5 | step Macro::Inject(session: 'services.session_token') 6 | step Rescue(JWTSessions::Errors::Unauthorized) { 7 | step :destroy_user_session 8 | } 9 | step Macro::Semantic(success: :destroyed) 10 | 11 | def destroy_user_session(_ctx, session:, found_token:, **) 12 | session.destroy(refresh_token: found_token) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/sessions/refreshes/operation/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Sessions::Refreshes::Operation 4 | class Create < ApplicationOperation 5 | step Macro::Inject(session: 'services.session_token') 6 | step Rescue(JWTSessions::Errors::Unauthorized) { 7 | step :refresh_user_tokens 8 | } 9 | fail Macro::Semantic(failure: :forbidden) 10 | step Macro::Semantic(success: :created) 11 | step Macro::Renderer(meta: :tokens) 12 | 13 | def refresh_user_tokens(ctx, session:, payload:, found_token:, **) 14 | ctx[:tokens] = session.refresh(payload: payload, refresh_token: found_token) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/concepts/api/v1/users/verifications/operation/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Verifications::Operation 4 | class Show < ApplicationOperation 5 | step Subprocess(Api::V1::Users::Lib::Operation::DecryptEmailToken), fast_track: true 6 | step :user_account_not_verified? 7 | fail Macro::AddContractError(base: 'errors.verification.user_account_already_verified') 8 | step :verify_user_account 9 | step :create_user 10 | step :send_notification 11 | 12 | def user_account_not_verified?(_ctx, model:, **) 13 | !model.verified? 14 | end 15 | 16 | def verify_user_account(_ctx, model:, **) 17 | model.toggle!(:verified) 18 | end 19 | 20 | def create_user(_ctx, model:, **) 21 | model.create_user 22 | end 23 | 24 | def send_notification(_ctx, model:, **) 25 | UserMailer.verification_successful(email: model.email).deliver_later 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/concepts/application_contract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationContract < Reform::Form 4 | feature Reform::Form::Dry 5 | end 6 | -------------------------------------------------------------------------------- /app/concepts/application_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationDecorator < Draper::Decorator 4 | end 5 | -------------------------------------------------------------------------------- /app/concepts/application_operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationOperation < Trailblazer::Operation 4 | end 5 | -------------------------------------------------------------------------------- /app/concepts/application_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationSerializer 4 | include JSONAPI::Serializer 5 | end 6 | -------------------------------------------------------------------------------- /app/concepts/application_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationWorker 4 | include Sidekiq::Worker 5 | end 6 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users/account/profiles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Account 4 | class ProfilesController < AuthorizedApiController 5 | def show 6 | endpoint operation: Api::V1::Users::Account::Profiles::Operation::Show 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users/registrations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users 4 | class RegistrationsController < ApiController 5 | def create 6 | endpoint operation: Api::V1::Users::Registrations::Operation::Create 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users/reset_passwords_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users 4 | class ResetPasswordsController < ApiController 5 | def create 6 | endpoint operation: Api::V1::Users::ResetPasswords::Operation::Create 7 | end 8 | 9 | def show 10 | endpoint operation: Api::V1::Users::ResetPasswords::Operation::Show 11 | end 12 | 13 | def update 14 | endpoint operation: Api::V1::Users::ResetPasswords::Operation::Update 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users/session/refreshes_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users::Session 4 | class RefreshesController < ApiController 5 | def create 6 | authorize_refresh_request! 7 | endpoint operation: Api::V1::Users::Sessions::Refreshes::Operation::Create, 8 | options: { found_token: found_token, payload: payload } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users 4 | class SessionsController < ApiController 5 | def create 6 | endpoint operation: Api::V1::Users::Sessions::Operation::Create 7 | end 8 | 9 | def destroy 10 | authorize_refresh_request! 11 | endpoint operation: Api::V1::Users::Sessions::Operation::Destroy, options: { found_token: found_token } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users/verifications_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api::V1::Users 4 | class VerificationsController < ApiController 5 | def show 6 | endpoint operation: Api::V1::Users::Verifications::Operation::Show 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/api_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApiController < ActionController::API 4 | include Authentication 5 | include SimpleEndpoint::Controller 6 | include DefaultEndpoint 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | end 5 | -------------------------------------------------------------------------------- /app/controllers/authorized_api_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AuthorizedApiController < ApiController 4 | before_action :authorize_access_request! 5 | 6 | private 7 | 8 | def endpoint_options 9 | { current_account: current_account, params: params } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/concerns/authentication.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Authentication 4 | extend ActiveSupport::Concern 5 | include JWTSessions::RailsAuthorization 6 | 7 | included do 8 | rescue_from JWTSessions::Errors::Unauthorized do 9 | render( 10 | jsonapi: Service::JsonApi::HashErrorSerializer.call(base: [I18n.t('errors.session.invalid_token')]), 11 | status: :unauthorized 12 | ) 13 | end 14 | end 15 | 16 | def current_account 17 | @current_account ||= Account.find_by(id: payload['account_id']) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | end 5 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: Rails.configuration.default_sender_email 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /app/mailers/user_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserMailer < ApplicationMailer 4 | def confirmation(email, token, path) 5 | @email = email 6 | @confirmation_url = URI.parse("#{path}?email_token=#{token}").to_s 7 | mail(to: email, subject: I18n.t('user_mailer.confirmation.subject')) 8 | end 9 | 10 | def verification_successful(email) 11 | @email = email 12 | mail(to: email, subject: I18n.t('user_mailer.verification_successful.subject')) 13 | end 14 | 15 | def reset_password(email, token, path) 16 | @email = email 17 | @reset_password_url = URI.parse("#{path}?email_token=#{token}").to_s 18 | mail(to: email, subject: I18n.t('user_mailer.reset_password.subject')) 19 | end 20 | 21 | def reset_password_successful(email) 22 | @email = email 23 | mail(to: email, subject: I18n.t('user_mailer.reset_password_successful.subject')) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/models/account.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account < ApplicationRecord 4 | has_secure_password 5 | has_one :user, dependent: :destroy 6 | end 7 | -------------------------------------------------------------------------------- /app/models/admin_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminUser < ApplicationRecord 4 | # Include default devise modules. Others available are: 5 | # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable 6 | devise :database_authenticatable, 7 | :recoverable, :rememberable, :validatable 8 | end 9 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | belongs_to :account 5 | end 6 | -------------------------------------------------------------------------------- /app/uploaders/application_uploader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationUploader < Shrine 4 | plugin :activerecord 5 | end 6 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/user_mailer/confirmation.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= t('.title') %> 3 |

4 | 5 |

6 | <%= link_to t('.confirm'), @confirmation_url %> 7 | <%= t('.message') %>: 8 | <%= @email %> 9 |

10 | -------------------------------------------------------------------------------- /app/views/user_mailer/confirmation.text.erb: -------------------------------------------------------------------------------- 1 | <%= t('.title') %> 2 | =============================================== 3 | 4 | <%= t('.email') %>: <%= @email %> 5 | <%= t('.confirmation_url') %>: <%= @confirmation_url %> 6 | -------------------------------------------------------------------------------- /app/views/user_mailer/reset_password.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= t('.title') %> 3 |

4 | 5 |

6 | <%= link_to t('.reset'), @reset_password_url %> 7 | <%= t('.message') %>: 8 | <%= @email %> 9 |

10 | -------------------------------------------------------------------------------- /app/views/user_mailer/reset_password.text.erb: -------------------------------------------------------------------------------- 1 | <%= t('.title') %> 2 | =============================================== 3 | 4 | <%= t('.reset') %> <%= t('.message') %>: <%= @email %> 5 | <%= @reset_password_url %> 6 | -------------------------------------------------------------------------------- /app/views/user_mailer/reset_password_successful.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= t('.title') %> 3 |

4 | -------------------------------------------------------------------------------- /app/views/user_mailer/reset_password_successful.text.erb: -------------------------------------------------------------------------------- 1 | <%= t('.title') %> 2 | =============================================== 3 | -------------------------------------------------------------------------------- /app/views/user_mailer/verification_successful.html.erb: -------------------------------------------------------------------------------- 1 |

2 | <%= t('.title') %> 3 |

4 | -------------------------------------------------------------------------------- /app/views/user_mailer/verification_successful.text.erb: -------------------------------------------------------------------------------- 1 | <%= t('.title') %> 2 | =============================================== 3 | -------------------------------------------------------------------------------- /app/webpacker/packs/active_admin.js: -------------------------------------------------------------------------------- 1 | // Load Active Admin's styles into Webpacker, 2 | // see `active_admin.scss` for customization. 3 | import "../stylesheets/active_admin"; 4 | 5 | import "@activeadmin/activeadmin"; 6 | -------------------------------------------------------------------------------- /app/webpacker/packs/active_admin/print.scss: -------------------------------------------------------------------------------- 1 | /* Active Admin Print Stylesheet */ 2 | @import "~@activeadmin/activeadmin/src/scss/print"; 3 | -------------------------------------------------------------------------------- /app/webpacker/stylesheets/active_admin.scss: -------------------------------------------------------------------------------- 1 | // SASS variable overrides must be declared before loading up Active Admin's styles. 2 | // 3 | // To view the variables that Active Admin provides, take a look at 4 | // `app/assets/stylesheets/active_admin/mixins/_variables.scss` in the 5 | // Active Admin source. 6 | // 7 | // For example, to change the sidebar width: 8 | // $sidebar-width: 242px; 9 | 10 | // Active Admin's got SASS! 11 | @import "~@activeadmin/activeadmin/src/scss/mixins"; 12 | @import "~@activeadmin/activeadmin/src/scss/base"; 13 | 14 | // Overriding any non-variable SASS must be done after the fact. 15 | // For example, to change the default status-tag color: 16 | // 17 | // .status_tag { background: #6090DB; } 18 | -------------------------------------------------------------------------------- /app/workers/application_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nocov: 4 | class ApplicationWorker 5 | include Sidekiq::Worker 6 | end 7 | # :nocov: 8 | -------------------------------------------------------------------------------- /app/workers/sentry_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SentryWorker < ApplicationWorker 4 | def perform(event) 5 | Raven.send_event(event) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | var validEnv = ['development', 'test', 'production'] 3 | var currentEnv = api.env() 4 | var isDevelopmentEnv = api.env('development') 5 | var isProductionEnv = api.env('production') 6 | var isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ] 38 | ].filter(Boolean), 39 | plugins: [ 40 | 'babel-plugin-macros', 41 | '@babel/plugin-syntax-dynamic-import', 42 | isTestEnv && 'babel-plugin-dynamic-import-node', 43 | '@babel/plugin-transform-destructuring', 44 | [ 45 | '@babel/plugin-proposal-class-properties', 46 | { 47 | loose: true 48 | } 49 | ], 50 | [ 51 | '@babel/plugin-proposal-object-rest-spread', 52 | { 53 | useBuiltIns: true 54 | } 55 | ], 56 | [ 57 | '@babel/plugin-transform-runtime', 58 | { 59 | helpers: false, 60 | regenerator: true, 61 | corejs: false 62 | } 63 | ], 64 | [ 65 | '@babel/plugin-transform-regenerator', 66 | { 67 | async: false 68 | } 69 | ] 70 | ].filter(Boolean) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /bin/docker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | system("docker-compose run --rm server_app #{ARGV.join(' ')}") 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | begin 5 | load File.expand_path('spring', __dir__) 6 | rescue LoadError => e 7 | raise unless e.message.include?('spring') 8 | end 9 | APP_PATH = File.expand_path('../config/application', __dir__) 10 | require_relative '../config/boot' 11 | require 'rails/commands' 12 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | begin 5 | load File.expand_path('spring', __dir__) 6 | rescue LoadError => e 7 | raise unless e.message.include?('spring') 8 | end 9 | require_relative '../config/boot' 10 | require 'rake' 11 | Rake.application.run 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to setup or update your development environment automatically. 15 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 16 | # Add necessary setup steps to this file. 17 | 18 | puts '== Installing dependencies ==' 19 | system! 'gem install bundler --conservative' 20 | system('bundle check') || system!('bundle install') 21 | 22 | # puts "\n== Copying sample files ==" 23 | # unless File.exist?('config/database.yml') 24 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 25 | # end 26 | 27 | puts "\n== Preparing database ==" 28 | system! 'bin/rails db:prepare' 29 | 30 | puts "\n== Removing old logs and tempfiles ==" 31 | system! 'bin/rails log:clear tmp:clear' 32 | 33 | puts "\n== Restarting application server ==" 34 | system! 'bin/rails restart' 35 | end 36 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # This file loads Spring without using Bundler, in order to be fast. 5 | # It gets overwritten when you run the `spring binstub` command. 6 | 7 | unless defined?(Spring) 8 | require 'rubygems' 9 | require 'bundler' 10 | 11 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 12 | spring = lockfile.specs.detect { |spec| spec.name == 'spring' } 13 | if spring 14 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 15 | gem 'spring', spring.version 16 | require 'spring/binstub' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::WebpackRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::DevServerRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails' 6 | require 'active_model/railtie' 7 | require 'active_job/railtie' 8 | require 'active_record/railtie' 9 | require 'action_controller/railtie' 10 | require 'action_mailer/railtie' 11 | 12 | Bundler.require(*Rails.groups) 13 | 14 | module BoilerplateRailsApi 15 | class Application < Rails::Application 16 | config.load_defaults 6.0 17 | config.i18n.load_path += Dir[Rails.root.join('config/locales/**/*.yml').to_s] 18 | config.eager_load_paths << Rails.root.join('lib') 19 | config.api_only = true 20 | config.active_job.queue_adapter = :sidekiq 21 | config.test_framework = :rspec 22 | 23 | config.middleware.use ActionDispatch::Flash 24 | config.middleware.use Rack::MethodOverride 25 | config.middleware.use ActionDispatch::Cookies 26 | config.middleware.use ActionDispatch::Session::CookieStore 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 7 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: boilerplate_rails_api_production 11 | -------------------------------------------------------------------------------- /config/credentials/credentials.yml: -------------------------------------------------------------------------------- 1 | aws: Do not use for Test and Development 2 | access_key_id: 3 | secret_access_key: 4 | s3: 5 | region: A region where we have stored a bucket 6 | bucket: S3 bucket name 7 | 8 | redis: 9 | db: The name of container in staging and production (redis://service_name:6379). 10 | redis://localhost:6379 for test and development. 11 | 12 | smtp: 13 | address: Domain address of email service 14 | user_name: An email 15 | password: A password from email box 16 | 17 | db: 18 | host: Domain of RDS instance 19 | port: 5432 20 | database: Database name from RDS 21 | user: User name for access to DB on RDS 22 | password: Password to RDS DB 23 | pool: 5 24 | 25 | sentry_dsn: Not used. Used for notify in a SLACK channel. 26 | -------------------------------------------------------------------------------- /config/credentials/development.yml.enc: -------------------------------------------------------------------------------- 1 | Cfuej3vrC56sKkzLpHx3AkFXRpWkJGS+HTULvYSX/Mof9JNq6Clb4A1Bt1uVBqzsinVZVKtKVzDVlszd+McyTzaN6VpEhhdh6bLOVnNrFBKInfasQDqOHGzBhnlRIxQplszbJNzQK9X1MMlZMRIKfbxmZnfytmTEr7VLUYjZ0V6fjLantGana8yaY8N/5+/K23dAL7kbsRLT0/CN4VmnP8O5TYRzA9m0tUdT9ZnZAGlPg3GnAs0ZkifB9lUUkE3bc7Ew5g9ZFtPEuQq6PwheTnA/YiR+f546RxlskNI=--S1jlmdaflYm63Guq--OGx3SXdTXln47x0LLSLGCg== -------------------------------------------------------------------------------- /config/credentials/production.yml.enc: -------------------------------------------------------------------------------- 1 | wsjpbGIVEulAtw20Dq87Yw5EEfoc/f0pX1ZxaEUn6qYvv8shJTEMuVzDNUof8Rqslb3bMavfMx2SPK53AE542RTpH5RdnVkERK0XYQYQugd8xxuQmxicxXYYvY2kuJ9TG3yup1q2twJUsaK9X6aFjkWrn4XFhmSHnI8KiE7lljFf0y3YfNKSGEzYg6S0i0mzZzGdujtDp9LU/dtXxpSKUyQnr4+xEjccfL8lQWewK/lSAE26ISJ4FeLFrWxc2Ke23hsmrQbwSc4qKtuMG1FXrT5kig164HI0TUUk8GVtSpM0/Nna7YupUS6SyTdCjdUaNixAqQHbMcoMBV8PIkSQRg==--v6GUK8BFsUPYGvxV--ZegcvljujSPl94y6qrKxGg== -------------------------------------------------------------------------------- /config/credentials/staging.yml.enc: -------------------------------------------------------------------------------- 1 | vhwEOhTQ0eQ4u2B7qx2zMNqzrcxS2qJZEH9KWFqsWUlYLE94WDftVtLQZMG1QAbdAx3K97k+KZ9DfczVz+NC51wBiWPycoBaiB0UF4obI5TKv3Tjy+wmIH6HAuJ24XQv91oxWdHC7JsFV55hANFbOMKQSVfnV7oP+M5NfOQluWAApuP6aPEHVsWyLysc4CWQXHhRIA3fsBTqfxNr01JLicubHvOOvcsvDwPu+EMG1ZvxpuzyrhRuUhTHVcX9+lbzbxlUOR7EioAAUXyQvxqD4l/dsbp5SJwoklQPr5dp0hQpN0F00ZiKLXwTkkNbyFVzP0LAU56wS7Hr44s=--peg5QxKY3TlK5lA9--jUW2ebCAuTvL2EP0HwRxYA== -------------------------------------------------------------------------------- /config/credentials/test.yml.enc: -------------------------------------------------------------------------------- 1 | 0tiY9V12p1Kpwncf8FFlTkKGpwhz1+sgKMqKLmCn1AY4H9JnEii6k1D3TIRZHvSbThRiniwoCKQle3QPQTDR4y8wT3RvU5QWx5CIaq23f2vzXdlwRQhhLM3lNrq7OXyOSC2tYzGgUX7RNTSm1+3rdkR4j/vIls05EKfKgyB7JX2zkXeJP4E0NsLcFtE1SFr2pNqIS41G+3vYglTj/21hukgUiRwq5SRX5gXngbFLtIKBxq1eHGBGBlH5eKYppmtVUOyQ3GbYjtqM8yahhpqjaV9clSU=--cAuDYkIUuWOSO3Ql--4jGa9qKxWSFzRR1ZwpRZbg== -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | pool: 5 5 | timeout: 5000 6 | host: <%= ENV['DB_HOST'] || Rails.application.credentials.dig(:db, :host) %> 7 | username: <%= ENV['DB_USERNAME'] || Rails.application.credentials.dig(:db, :user) %> 8 | password: <%= ENV['DB_PASSWORD'] || Rails.application.credentials.dig(:db, :password) %> 9 | database: <%= Rails.application.credentials.dig(:db, :database) %> 10 | 11 | development: 12 | <<: *default 13 | database: boilerplate_development 14 | staging: 15 | <<: *default 16 | database: boilerplate_staging 17 | production: 18 | <<: *default 19 | database: boilerplate 20 | test: 21 | <<: *default 22 | database: boilerplate_test 23 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # ActiveSupport::Reloader.to_prepare do 6 | # ApplicationController.renderer.defaults.merge!( 7 | # http_host: 'example.org', 8 | # https: false 9 | # ) 10 | # end 11 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 9 | # Rails.backtrace_cleaner.remove_silencers! 10 | -------------------------------------------------------------------------------- /config/initializers/constants/shared.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Constants 4 | module Shared 5 | EMAIL_REGEX = /\A(.+)@([a-z0-9]+([\-.]{1}[a-z0-9]+)*\.[a-z]{2,63})\z/i.freeze 6 | PASSWORD_REGEX = /\A(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[-_!@#$%\^&*])/.freeze 7 | PASSWORD_MIN_SIZE = 8 8 | EMAIL_MAX_LENGTH = 255 9 | HMAC_SECRET = Rails.env.test? ? 'test' : Rails.application.credentials.secret_key_base 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/constants/tokens_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Constants 4 | module TokenNamespace 5 | SESSION = 'user-account' 6 | RESET_PASSWORD = 'reset-password' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/initializers/cors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Avoid CORS issues when API is called from the frontend app. 6 | # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. 7 | 8 | # Read more: https://github.com/cyu/rack-cors 9 | 10 | Rails.application.config.middleware.insert_before 0, Rack::Cors do 11 | allow do 12 | # origins 'localhost:3000' 13 | 14 | resource '*', 15 | headers: :any, 16 | methods: %i[get post put patch delete options head] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/initializers/dry_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dry::Validation::Schema.configure do |config| 4 | config.messages = :i18n 5 | config.input_processor = :sanitizer 6 | end 7 | 8 | Dry::Validation::Schema::Form.configure do |config| 9 | config.messages = :i18n 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += [:password] 7 | -------------------------------------------------------------------------------- /config/initializers/generators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.config.generators do |g| 4 | g.orm :active_record, primary_key_type: :uuid 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new inflection rules using the following format. Inflections 6 | # are locale specific, and you may define rules for as many different 7 | # locales as you wish. All of these examples are active by default: 8 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 9 | # inflect.plural /^(ox)$/i, '\1en' 10 | # inflect.singular /^(ox)en/i, '\1' 11 | # inflect.irregular 'person', 'people' 12 | # inflect.uncountable %w( fish sheep ) 13 | # end 14 | 15 | # These inflection rules are supported but not enabled by default: 16 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 17 | # inflect.acronym 'RESTful' 18 | # end 19 | -------------------------------------------------------------------------------- /config/initializers/json_api/filtering.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JsonApi 4 | module Filtering 5 | module Operators 6 | MATCH_ALL = 'all_filters' 7 | MATCH_ANY = 'any_filters' 8 | end 9 | 10 | OPERATORS = [Operators::MATCH_ALL, Operators::MATCH_ANY].freeze 11 | 12 | module Predicates 13 | IN = 'in' 14 | NOT_IN = 'not_in' 15 | EQUAL = 'eq' 16 | EQUAL_RELATIVE = 'eq_rel' 17 | NOT_EQUAL = 'not_eq' 18 | NOT_EQUAL_RELATIVE = 'not_eq_rel' 19 | CONTAINS = 'cont' 20 | NOT_CONTAINS = 'not_cont' 21 | GREATER_THAN = 'gt' 22 | GREATER_THAN_OR_EQUAL = 'gteq' 23 | LESS_THAN = 'lt' 24 | TRUE = 'true' 25 | FALSE = 'false' 26 | NULL = 'null' 27 | NOT_NULL = 'not_null' 28 | GREATER_THAN_RELATIVE = 'gt_rel' 29 | LESS_THAN_RELATIVE = 'lt_rel' 30 | end 31 | 32 | PREDICATES = { 33 | string: [ 34 | Predicates::IN, 35 | Predicates::NOT_IN, 36 | Predicates::EQUAL, 37 | Predicates::NOT_EQUAL, 38 | Predicates::CONTAINS, 39 | Predicates::NOT_CONTAINS 40 | ].to_set, 41 | number: [ 42 | Predicates::IN, 43 | Predicates::NOT_IN, 44 | Predicates::EQUAL, 45 | Predicates::NOT_EQUAL, 46 | Predicates::GREATER_THAN, 47 | Predicates::GREATER_THAN_OR_EQUAL, 48 | Predicates::NULL, 49 | Predicates::NOT_NULL 50 | ].to_set, 51 | boolean: [ 52 | Predicates::TRUE, 53 | Predicates::FALSE 54 | ].to_set, 55 | date: [ 56 | Predicates::EQUAL, 57 | Predicates::NOT_EQUAL, 58 | Predicates::GREATER_THAN, 59 | Predicates::LESS_THAN, 60 | Predicates::EQUAL_RELATIVE, 61 | Predicates::NOT_EQUAL_RELATIVE, 62 | Predicates::GREATER_THAN_RELATIVE, 63 | Predicates::LESS_THAN_RELATIVE, 64 | Predicates::NULL, 65 | Predicates::NOT_NULL 66 | ].to_set 67 | }.freeze 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /config/initializers/json_api/pagination.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JsonApi 4 | module Pagination 5 | MINIMAL_VALUE = 1 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/json_api/sorting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JsonApi 4 | module Sorting 5 | JSONAPI_SORT_PATTERN = /\A(-)?(.+)/.freeze 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/jwt_sessions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | JWTSessions.encryption_key = Constants::Shared::HMAC_SECRET 4 | JWTSessions.token_store = Rails.application.config.token_store 5 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | JSONAPI_MEDIA_TYPE = 'application/vnd.api+json' 4 | 5 | Mime::Type.register(JSONAPI_MEDIA_TYPE, :jsonapi) 6 | 7 | ActionController::Renderers.add(:jsonapi) do |json, options| 8 | json = json.to_json(options) unless json.is_a?(String) 9 | self.content_type ||= Mime[:jsonapi] 10 | self.response_body = json 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/oj.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Oj.optimize_rails 4 | -------------------------------------------------------------------------------- /config/initializers/pagy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pagy/extras/overflow' 4 | require 'pagy/extras/array' 5 | 6 | Pagy::VARS[:items] = 25 7 | Pagy::VARS[:overflow] = :empty_page 8 | -------------------------------------------------------------------------------- /config/initializers/ransack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Ransack.configure do |config| 4 | config.add_predicate 'date_equals', 5 | arel_predicate: 'eq', 6 | formatter: proc(&:to_date), 7 | validator: proc(&:present?), 8 | type: :string 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/redis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Redis.current = 4 | Rails.env.test? ? MockRedis.new : Redis.new(url: (ENV['REDIS_DB'] || Rails.application.credentials.redis[:db])) 5 | -------------------------------------------------------------------------------- /config/initializers/reform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'reform' 4 | require 'reform/form/dry' 5 | require 'reform/form/coercion' 6 | 7 | module PatchErrorCompiler 8 | def call(fields, reform_errors, form) 9 | @validator.with(form: form).call(fields).errors.each do |field, dry_error| 10 | dry_error.each do |attr_error| 11 | reform_errors.add(field, attr_error) 12 | end 13 | end 14 | end 15 | end 16 | 17 | Reform::Form::Dry::Validations::Group.prepend(PatchErrorCompiler) 18 | -------------------------------------------------------------------------------- /config/initializers/rswag_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rswag::Api.configure do |c| 4 | # Specify a root folder where Swagger JSON files are located 5 | # This is used by the Swagger middleware to serve requests for API descriptions 6 | # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure 7 | # that it's configured to generate files in the same folder 8 | c.swagger_root = Rails.root.join('swagger').to_s 9 | 10 | # Inject a lamda function to alter the returned Swagger prior to serialization 11 | # The function will have access to the rack env for the current request 12 | # For example, you could leverage this to dynamically assign the "host" property 13 | # 14 | # c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/rswag_ui.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rswag::Ui.configure do |c| 4 | # List the Swagger endpoints that you want to be documented through the swagger-ui 5 | # The first parameter is the path (absolute or relative to the UI host) to the corresponding 6 | # endpoint and the second is a title that will be displayed in the document selector 7 | # NOTE: If you're using rspec-api to expose Swagger files (under swagger_root) as JSON or YAML endpoints, 8 | # then the list below should correspond to the relative paths for those endpoints 9 | 10 | c.swagger_endpoint '/api-docs/v1/swagger.yaml', 'API V1 Docs' 11 | 12 | # Add Basic Auth in case your API is private 13 | # c.basic_auth_enabled = true 14 | # c.basic_auth_credentials 'username', 'password' 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/sentry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Raven.configure do |config| 4 | config.sanitize_fields = Rails.application.config.filter_parameters.map(&:to_s) 5 | config.dsn = Rails.application.credentials[:sentry_dsn] 6 | config.environments = %w[production staging] 7 | config.async = lambda { |event| 8 | SentryWorker.perform_async(event) 9 | } 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/shrine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'shrine' 4 | -------------------------------------------------------------------------------- /config/initializers/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sidekiq/web' 4 | 5 | Sidekiq.configure_client do |config| 6 | config.redis = { url: ENV['REDIS_DB'] || Rails.application.credentials.redis[:db] } 7 | end 8 | 9 | Sidekiq.configure_server do |config| 10 | config.redis = { url: ENV['REDIS_DB'] || Rails.application.credentials.redis[:db] } 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/trailblazer_macro.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dir[Rails.root.join('lib/macro/**/*.rb')].sort.each { |file| require file } 4 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /config/initializers/zeitwerk.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.autoloaders.main.ignore(Rails.root.join('app/admin')) 4 | Rails.autoloaders.main.ignore(Rails.root.join('lib/macro')) 5 | Rails.autoloaders.main.ignore(Rails.root.join('app/channels/application_cable')) 6 | -------------------------------------------------------------------------------- /config/locales/en.errors.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | errors: 4 | email_token: 5 | already_used: email token is already used 6 | email_uniq?: email not unique 7 | filtering_column_valid?: invalid filtering column 8 | filtering_predicate_valid?: invalid filtering predicate 9 | filtering_value_valid?: invalid filtering value 10 | filters_uniq?: filters not unique 11 | inclusion_params_uniq?: inclusion params not unique 12 | inclusion_params_valid?: can't include such association 13 | pagination_overflow: is out of limits 14 | reset_password: 15 | email_not_found: Email not found 16 | rules: 17 | user_password: 18 | eql?: should match to password 19 | session: 20 | invalid_token: invalid_token 21 | wrong_credentials: Invalid email or password 22 | sort_params_uniq?: sort params not unique 23 | sort_params_valid?: invalid sort params 24 | verification: 25 | invalid_email_token: invalid email token 26 | -------------------------------------------------------------------------------- /config/locales/en.user_mailer.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | user_mailer: 4 | confirmation: 5 | confirm: Confirm 6 | confirmation_url: Confirmation url 7 | email: Email 8 | message: my email 9 | subject: Email confirmation 10 | title: Email verification 11 | reset_password: 12 | message: current user account password for 13 | reset: Reset 14 | subject: Reset password 15 | title: Reset user account password 16 | reset_password_successful: 17 | subject: Your user account password has been changed 18 | title: Your user account password has been changed 19 | verification_successful: 20 | subject: Your account has been verified successfully 21 | title: Your account has been verified successfully 22 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Puma can serve each request in a thread from an internal thread pool. 4 | # The `threads` method setting takes two numbers: a minimum and maximum. 5 | # Any libraries that use thread pools should be configured to match 6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 7 | # and maximum; this matches the default thread size of Active Record. 8 | # 9 | max_threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 } 10 | min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } 11 | threads min_threads_count, max_threads_count 12 | 13 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 14 | # 15 | port ENV.fetch('PORT') { 3000 } 16 | 17 | # Specifies the `environment` that Puma will run in. 18 | # 19 | environment ENV.fetch('RAILS_ENV') { 'development' } 20 | 21 | # Specifies the number of `workers` to boot in clustered mode. 22 | # Workers are forked web server processes. If using threads and workers together 23 | # the concurrency of the application would be max `threads` * `workers`. 24 | # Workers do not work on JRuby or Windows (both of which do not support 25 | # processes). 26 | # 27 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 28 | 29 | # Use the `preload_app!` method when specifying a `workers` number. 30 | # This directive tells Puma to first boot the application and load code 31 | # before forking the application. This takes advantage of Copy On Write 32 | # process behavior so workers use less memory. 33 | # 34 | # preload_app! 35 | 36 | # Allow puma to be restarted by `rails restart` command. 37 | plugin :tmp_restart 38 | -------------------------------------------------------------------------------- /config/rails_best_practices.yml: -------------------------------------------------------------------------------- 1 | AddModelVirtualAttributeCheck: { } 2 | AlwaysAddDbIndexCheck: { } 3 | #CheckSaveReturnValueCheck: { } 4 | #CheckDestroyReturnValueCheck: { } 5 | DefaultScopeIsEvilCheck: { } 6 | DryBundlerInCapistranoCheck: { } 7 | #HashSyntaxCheck: { } 8 | IsolateSeedDataCheck: { } 9 | KeepFindersOnTheirOwnModelCheck: { } 10 | LawOfDemeterCheck: { } 11 | #LongLineCheck: { max_line_length: 80 } 12 | MoveCodeIntoControllerCheck: { } 13 | MoveCodeIntoHelperCheck: { array_count: 3 } 14 | MoveCodeIntoModelCheck: { use_count: 2 } 15 | MoveFinderToNamedScopeCheck: { } 16 | MoveModelLogicIntoModelCheck: { 17 | use_count: 4, 18 | ignored_files: ['app/controllers/concerns/default_endpoint.rb'] 19 | } 20 | NeedlessDeepNestingCheck: { nested_count: 2 } 21 | NotRescueExceptionCheck: { } 22 | NotUseDefaultRouteCheck: { } 23 | NotUseTimeAgoInWordsCheck: { } 24 | OveruseRouteCustomizationsCheck: { customize_count: 3 } 25 | ProtectMassAssignmentCheck: { } 26 | RemoveEmptyHelpersCheck: { } 27 | #RemoveTabCheck: { } 28 | RemoveTrailingWhitespaceCheck: { } 29 | RemoveUnusedMethodsInControllersCheck: { except_methods: [ 30 | AuthorizedApiController#endpoint_options 31 | ] } 32 | RemoveUnusedMethodsInHelpersCheck: { except_methods: [] } 33 | RemoveUnusedMethodsInModelsCheck: { except_methods: [] } 34 | ReplaceComplexCreationWithFactoryMethodCheck: { attribute_assignment_count: 2 } 35 | ReplaceInstanceVariableWithLocalVariableCheck: { } 36 | RestrictAutoGeneratedRoutesCheck: { } 37 | SimplifyRenderInControllersCheck: { } 38 | SimplifyRenderInViewsCheck: { } 39 | #UseBeforeFilterCheck: { customize_count: 2 } 40 | UseModelAssociationCheck: { } 41 | UseMultipartAlternativeAsContentTypeOfEmailCheck: { } 42 | #UseParenthesesInMethodDefCheck: { } 43 | UseObserverCheck: { } 44 | UseQueryAttributeCheck: { } 45 | UseSayWithTimeInMigrationsCheck: { } 46 | UseScopeAccessCheck: { } 47 | UseTurboSprocketsRails3Check: { } 48 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | mount Rswag::Ui::Engine => '/api-docs' 5 | mount Rswag::Api::Engine => '/api-docs' 6 | devise_for :admin_users, ActiveAdmin::Devise.config 7 | ActiveAdmin.routes(self) 8 | authenticate :admin_user do 9 | mount Sidekiq::Web => '/sidekiq' 10 | end 11 | 12 | get 'health', to: proc { [200, {}, ['success']] } 13 | root to: 'admin/dashboard#index' 14 | 15 | namespace :api do 16 | namespace :v1 do 17 | namespace :users do 18 | resource :registration, only: :create 19 | resource :verification, only: :show 20 | resource :session, only: %i[create destroy] do 21 | scope module: :session do 22 | resource :refresh, only: :create 23 | end 24 | end 25 | resource :reset_password, only: %i[create show update] 26 | namespace :account do 27 | resource :profile, only: :show 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :concurrency: 1 3 | :queues: 4 | - default 5 | - mailers 6 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spring.watch( 4 | '.ruby-version', 5 | '.rbenv-vars', 6 | 'tmp/restart.txt', 7 | 'tmp/caching-dev.txt' 8 | ) 9 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | const jquery = require('./plugins/jquery') 3 | 4 | environment.plugins.prepend('jquery', jquery) 5 | module.exports = environment 6 | -------------------------------------------------------------------------------- /config/webpack/plugins/jquery.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | module.exports = new webpack.ProvidePlugin({ 4 | "$":"jquery", 5 | "jQuery":"jquery", 6 | "window.jQuery":"jquery" 7 | }); 8 | -------------------------------------------------------------------------------- /config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /db/migrate/20190813110526_enable_uuid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EnableUuid < ActiveRecord::Migration[6.0] 4 | def change 5 | enable_extension 'pgcrypto' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20190813110527_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :users, id: :uuid do |t| 4 | t.uuid :account_id, index: true 5 | t.string :name 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20190813124813_create_accounts.rb: -------------------------------------------------------------------------------- 1 | class CreateAccounts < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :accounts, id: :uuid do |t| 4 | t.string :email, index: { unique: true } 5 | t.string :password_digest 6 | t.boolean :verified, default: false 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20200831072010_devise_create_admin_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeviseCreateAdminUsers < ActiveRecord::Migration[6.0] 4 | def change 5 | create_table :admin_users, id: :uuid do |t| 6 | t.string :email, null: false, default: "" 7 | t.string :encrypted_password, null: false, default: "" 8 | t.string :reset_password_token 9 | t.datetime :reset_password_sent_at 10 | t.datetime :remember_created_at 11 | t.timestamps null: false 12 | end 13 | 14 | add_index :admin_users, :email, unique: true 15 | add_index :admin_users, :reset_password_token, unique: true 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file should contain all the record creation needed to seed the database with its default values. 4 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 5 | # 6 | # Examples: 7 | # 8 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 9 | # Character.create(name: 'Luke', movie: movies.first) 10 | -------------------------------------------------------------------------------- /dip.yml: -------------------------------------------------------------------------------- 1 | # Required minimum dip version 2 | version: '4.1' 3 | 4 | environment: 5 | COMPOSE_EXT: development 6 | 7 | compose: 8 | files: 9 | - docker-compose.yml 10 | project_name: boilerplate 11 | 12 | interaction: 13 | bash: 14 | description: Open the Bash shell in app's container 15 | service: server_app 16 | command: bash 17 | compose: 18 | run_options: [no-deps] 19 | 20 | bundle: 21 | description: Run Bundler commands 22 | service: server_app 23 | command: bundle 24 | 25 | rspec: 26 | description: Run Rspec commands 27 | service: server_app 28 | environment: 29 | RAILS_ENV: test 30 | command: bundle exec rspec 31 | 32 | rails: 33 | description: Run Rails commands 34 | service: server_app 35 | command: bundle exec rails 36 | subcommands: 37 | s: 38 | description: Run Rails server at http://localhost:3000 39 | service: server_app 40 | compose: 41 | run_options: [service-ports, use-aliases] 42 | 43 | sidekiq: 44 | description: Run sidekiq in background 45 | service: server_worker_app 46 | compose: 47 | method: up 48 | run_options: [detach] 49 | 50 | psql: 51 | description: Run Postgres psql console 52 | service: server_app 53 | default_args: boilerplate_rails_api_development 54 | command: psql -h db -p 5432 -U postgres 55 | 56 | provision: 57 | - dip compose down --volumes 58 | - dip compose up 59 | -------------------------------------------------------------------------------- /lib/container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Container 4 | extend Dry::Container::Mixin 5 | 6 | namespace 'adapters' do 7 | register('redis') { Api::V1::Users::Lib::Service::RedisAdapter } 8 | end 9 | 10 | namespace 'services' do 11 | register('pagy') { Service::Pagy } 12 | register('email_token') { Api::V1::Users::Lib::Service::EmailToken } 13 | register('session_token') { Api::V1::Users::Lib::Service::SessionToken } 14 | register('token_namespace') { Api::V1::Users::Lib::Service::TokenNamespace } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/macro/add_contract_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.AddContractError(name: :default, **args) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | error, message = args.first 8 | ctx["contract.#{name}"].errors.add( 9 | error, message.is_a?(Array) ? message : I18n.t(message) 10 | ) 11 | } 12 | ) 13 | { task: task, id: "#{self.name}/#{__method__}_id_#{task.object_id}".underscore } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/macro/assign.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Assign(to:, path: [], value: nil, try: false) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | method_name = try ? :try : :public_send 8 | ctx[to] = value || path.drop(1).inject(ctx[path.first], method_name) 9 | } 10 | ) 11 | { task: task, id: "#{name}/#{__method__}_#{path.join('.')}_to_#{to}_id_#{task.object_id}".underscore } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/macro/decorate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Decorate(decorator: nil, from: :model, to: :model, **) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | model = ctx[from] 8 | ctx[to] = (decorator || ctx[:decorator]).public_send( 9 | (model.is_a?(Enumerable) ? :decorate_collection : :decorate), model 10 | ) 11 | } 12 | ) 13 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/macro/inject.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Inject(**deps) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | deps.each { |key, value| ctx[key] = value.is_a?(String) ? Container[value] : value } 8 | } 9 | ) 10 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/macro/links_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.LinksBuilder(resource_path: nil, ids: [], **) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | pagy = ctx[:pagy] 8 | resource_ids = ids.map { |id| ctx[:params][id] } 9 | ctx[:links] = 10 | if resource_path && pagy 11 | Service::JsonApi::Paginator.call( 12 | resource_path: Rails.application.routes.url_helpers.public_send(*[resource_path, *resource_ids].compact), 13 | pagy: pagy 14 | ) 15 | end 16 | } 17 | ) 18 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/macro/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Model(entity:, connections: [], find_by_key: :id, params_key: :id, assign: false) 5 | task = ->((ctx, flow_options), **) { 6 | if assign 7 | Macro::Assign(to: :model, path: [*entity, *connections], try: true)[:task].call(ctx, **{ options: [] }) 8 | else 9 | ctx[:model] = Model.find_relation(ctx[entity], connections, find_by_key, ctx[:params][params_key]) 10 | end 11 | 12 | ctx['result.model'] = Model.result(ctx[:model]) 13 | 14 | [Model.direction(ctx[:model].present?), [ctx, flow_options]] 15 | } 16 | 17 | { task: task, id: "#{entity}_model_id#{task.object_id}" } 18 | end 19 | 20 | module Model 21 | def self.direction(result) 22 | return Trailblazer::Activity::Right if result 23 | 24 | Trailblazer::Activity::Left 25 | end 26 | 27 | def self.result(model) 28 | Trailblazer::Operation::Result.new(model.present?, model) 29 | end 30 | 31 | def self.find_relation(entity, connections, find_by_key, params_key) 32 | connections.inject(entity, :public_send).find_by(find_by_key => params_key) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/macro/model_remove.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.ModelRemove(path: [], type: :destroy) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | model = ctx[path.first] 8 | types = %i[destroy delete destroy! delete!] 9 | call_chain = path[1..] 10 | return unless types.include?(type) 11 | 12 | call_chain.empty? ? model.public_send(type) : call_chain.push(type).inject(model, :try) 13 | } 14 | ) 15 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/macro/policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Policy(policy_class, rule, policy_params: nil, name: :default, model: :model, **) 5 | task = ->((ctx, flow_options), **) { 6 | policy_namespace = :"macro.policy.#{name}" 7 | ctx[policy_namespace] = policy_class.new(ctx[:current_user], ctx[model]) 8 | result = if policy_params 9 | ctx[policy_namespace].public_send(rule, ctx[:policy_params]) 10 | else 11 | ctx[policy_namespace].public_send(rule) 12 | end 13 | unless result 14 | message = "#{policy_class.name.demodulize.underscore}.#{rule}" || 'default' 15 | current_errors = ctx[:errors] || {} 16 | ctx[:errors] = current_errors.deep_merge( 17 | { 18 | policy: [I18n.t("policy.errors.#{message}")] 19 | } 20 | ) 21 | 22 | ctx[:semantic_failure] = :forbidden 23 | end 24 | 25 | signal = result ? Trailblazer::Activity::Right : Trailblazer::Activity::Left 26 | [signal, [ctx, flow_options]] 27 | } 28 | 29 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/macro/renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Renderer(serializer: ApplicationSerializer, meta: nil, **) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | ctx[:renderer] = 8 | { 9 | serializer: serializer, 10 | include: ctx[:inclusion_options], 11 | links: ctx[:links], 12 | meta: meta ? ctx[meta] : nil 13 | }.compact 14 | } 15 | ) 16 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/macro/semantic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Macro 4 | def self.Semantic(success: nil, failure: nil, **) 5 | task = Trailblazer::Activity::TaskBuilder::Binary( 6 | ->(ctx, **) { 7 | ctx[:semantic_success], ctx[:semantic_failure] = success, failure 8 | true 9 | } 10 | ) 11 | { task: task, id: "#{name}/#{__method__}_id_#{task.object_id}".underscore } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/service/json_api/base_error_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | class BaseErrorSerializer 6 | ERRORS_SOURCES = { contract_errors_matcher: 'contract.' }.freeze 7 | 8 | private_class_method :new 9 | 10 | def self.call(result) 11 | new(result).jsonapi_errors_hash 12 | end 13 | 14 | def initialize(result, errors_source = nil) 15 | @errors = custom_errors(result, errors_source) || contract_errors(result) 16 | end 17 | 18 | def jsonapi_errors_hash 19 | { errors: parse_errors } 20 | end 21 | 22 | private 23 | 24 | attr_reader :errors 25 | 26 | # :reek:UtilityFunction 27 | def custom_errors(result, errors_source) 28 | result[errors_source]&.errors&.messages 29 | end 30 | 31 | # :reek:UtilityFunction 32 | def contract_errors(result) 33 | contracts = result.keys.select do |result_key| 34 | result_key.start_with?(ERRORS_SOURCES[:contract_errors_matcher]) && 35 | result[result_key].try(:errors) 36 | end.compact 37 | 38 | contracts.map { |contract| result[contract].errors.messages }.reduce(:merge) 39 | end 40 | 41 | def parse_errors; end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/service/json_api/error_data_structure_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | module ErrorDataStructureParser 6 | private 7 | 8 | def plain_errors?(errors) 9 | errors.all?(String) 10 | end 11 | 12 | def compose_errors(field, messages) 13 | { source: { pointer: "/#{field}" }, detail: messages.join(', ') } 14 | end 15 | 16 | def parse_nested_errors_hash(errors_hash, pointer, memo) 17 | errors_hash.each do |key, error| 18 | new_pointer = "#{pointer}/#{key}" 19 | 20 | if error.is_a?(Hash) 21 | parse_nested_errors_hash(error, new_pointer, memo) 22 | else 23 | memo << compose_errors(new_pointer, error) 24 | end 25 | end 26 | end 27 | 28 | def parse_nested_errors_arrays_array(errors_arrays_array, pointer, memo) 29 | errors_arrays_array.each do |errors_array| 30 | new_pointer = "#{pointer}/#{errors_array.first}" 31 | nested_errors = errors_array.second 32 | 33 | if plain_errors?(nested_errors) 34 | memo << compose_errors(new_pointer, nested_errors) 35 | else 36 | parse_nested_errors_hash(nested_errors, new_pointer, memo) 37 | end 38 | end 39 | end 40 | 41 | def parse_errors 42 | errors.each_with_object([]) do |(field, error_value), memo| 43 | if plain_errors?(error_value) 44 | memo << compose_errors(field, error_value) 45 | else 46 | parse_nested_errors_arrays_array(error_value, field, memo) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/service/json_api/hash_error_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | class HashErrorSerializer < Service::JsonApi::BaseErrorSerializer 6 | include Service::JsonApi::ErrorDataStructureParser 7 | 8 | def initialize(**result) 9 | @errors = result 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/service/json_api/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | class Paginator 6 | QUERY_PAGE_PARAMETER = 'page[number]' 7 | 8 | class << self 9 | def call(resource_path:, pagy:) 10 | { 11 | self: page_path(resource_path, pagy.page), 12 | first: resource_path, 13 | next: page_path(resource_path, pagy.next), 14 | prev: page_path(resource_path, pagy.prev), 15 | last: page_path(resource_path, pagy.pages) 16 | } 17 | end 18 | 19 | private 20 | 21 | def page_path(resource_path, page) 22 | return if page.blank? 23 | 24 | "#{resource_path}?#{QUERY_PAGE_PARAMETER}=#{page}" 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/service/json_api/resource_error_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | class ResourceErrorSerializer < Service::JsonApi::BaseErrorSerializer 6 | include Service::JsonApi::ErrorDataStructureParser 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/service/json_api/resource_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | class ResourceSerializer 6 | private_class_method :new 7 | 8 | def self.call(result) 9 | new(result).call 10 | end 11 | 12 | def initialize(result) 13 | renderer = result[:renderer] 14 | @serializer = renderer.delete(:serializer) 15 | @options = renderer 16 | @object = result[:model] 17 | end 18 | 19 | def call 20 | serializer.new(object, options) 21 | end 22 | 23 | private 24 | 25 | attr_reader :serializer, :options, :object 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/service/json_api/uri_query_error_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | module JsonApi 5 | class UriQueryErrorSerializer < Service::JsonApi::BaseErrorSerializer 6 | ERRORS_SOURCE = 'contract.uri_query' 7 | 8 | def initialize(result) 9 | super(result, Service::JsonApi::UriQueryErrorSerializer::ERRORS_SOURCE) 10 | end 11 | 12 | private 13 | 14 | def compose_nested_errors(field, attribute_errors) 15 | attribute_errors.map do |nested_field, errors| 16 | { 17 | source: { pointer: "#{field}[#{nested_field}]" }, 18 | detail: errors.join(', ') 19 | } 20 | end 21 | end 22 | 23 | def parse_errors 24 | errors.flat_map do |field, attribute_errors| 25 | if attribute_errors.flatten.any? { |item| !item.is_a?(String) } 26 | compose_nested_errors(field, attribute_errors) 27 | else 28 | { source: { pointer: field.to_s }, detail: attribute_errors.join } 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/service/pagy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Service 4 | class Pagy 5 | class << self 6 | include ::Pagy::Backend 7 | 8 | def call(collection, page:, items:, **) 9 | pagy_method = collection.is_a?(Array) ? :pagy_array : :pagy 10 | send(pagy_method, collection, page: page, items: items) 11 | end 12 | 13 | private 14 | 15 | define_method(:params) { {} } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tasks/factory_bot_linter.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nocov: 4 | namespace :factory_bot do 5 | desc 'Verify that all FactoryBot factories are valid' 6 | task lint: :environment do 7 | if Rails.env.test? 8 | ActiveRecord::Base.connection.transaction do 9 | FactoryBot.lint 10 | raise ActiveRecord::Rollback 11 | end 12 | else 13 | system("bundle exec rake factory_bot:lint RAILS_ENV='test'") 14 | fail if $?.exitstatus.nonzero? # rubocop:disable Style/SignalException, Style/SpecialGlobalVars 15 | end 16 | end 17 | end 18 | # :nocov: 19 | -------------------------------------------------------------------------------- /lib/tasks/generate_api_documentation.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nocov: 4 | namespace :api do 5 | namespace :doc do 6 | desc 'Generate API Blueprint documentation' 7 | task :apib, :version do |_task, arg| 8 | require 'rspec/core/rake_task' 9 | 10 | version = arg[:version] 11 | apib_file = "public/api/docs/#{version}/index.apib" 12 | RSpec::Core::RakeTask.new(:api_spec) do |t| 13 | t.pattern = "spec/requests/api/#{version}" 14 | t.rspec_opts = "-f Dox::Formatter --order defined --tag dox --out #{apib_file}" 15 | end 16 | 17 | Rake::Task['api_spec'].invoke 18 | end 19 | 20 | desc 'Generate API HTML documentation' 21 | task :html, [:version] => [:apib] do |_task, arg| 22 | version = arg[:version] 23 | apib_file = "public/api/docs/#{version}/index.apib" 24 | html_file = "public/api/docs/#{version}/index.html" 25 | 26 | system("snowboard html -o #{html_file} #{apib_file}") 27 | end 28 | 29 | def generate_docs(version) 30 | Rake::Task['api:doc:html'].invoke(version) 31 | end 32 | 33 | desc 'Generate API Blueprint and HTML documentation for the v1' 34 | task :v1 do 35 | generate_docs('v1') 36 | end 37 | end 38 | end 39 | # :nocov: 40 | -------------------------------------------------------------------------------- /lib/types/json_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | include Dry::Types.module 5 | 6 | FilterObject = Struct.new(:column, :predicate, :value) 7 | SortObject = Struct.new(:column, :order) 8 | 9 | module JsonApi 10 | module TypeByColumn 11 | def self.call(column) 12 | { 13 | string: ::Types::String | ::Types::Array.of(::Types::String), 14 | number: ::Types::Form::Int | 15 | ::Types::Form::Decimal | 16 | ::Types::Array.of(::Types::Int) | 17 | ::Types::Array.of(::Types::Decimal), 18 | boolean: ::Types::Form::True | ::Types::Form::False | ::Types::Form::Nil, 19 | date: ::Types::Form::Date | ::Types::Form::Int 20 | }[column] 21 | end 22 | end 23 | 24 | Filter = Types::Array.constructor do |parameter| 25 | parameter.map do |key, value| 26 | column, predicate = key.split('-') 27 | Types::FilterObject.new(column, predicate, value) 28 | end 29 | end 30 | 31 | Sort = Types::Array.constructor do |parameter| 32 | parameter.split(',').map do |sort_object| 33 | order, column = sort_object.scan(::JsonApi::Sorting::JSONAPI_SORT_PATTERN).flatten 34 | Types::SortObject.new(column, order ? :desc : :asc) 35 | end 36 | end 37 | 38 | Include = Types::Array.constructor { |parameter| parameter.split(',') } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/log/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@activeadmin/activeadmin": "^2.8.0", 4 | "@rails/webpacker": "5.2.1" 5 | }, 6 | "devDependencies": { 7 | "webpack-dev-server": "^3.11.2" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/lib/operation/inclusion_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Lib::Operation::Inclusion do 4 | subject(:result) do 5 | described_class.call(params: params, available_inclusion_options: available_inclusion_options) 6 | end 7 | 8 | let(:valid_inclusion_parameter) { 'valid_inclusion_parameter' } 9 | let(:inclusion_params) { valid_inclusion_parameter } 10 | let(:available_inclusion_options) { [valid_inclusion_parameter] } 11 | let(:params) { { include: inclusion_params } } 12 | 13 | describe 'Success' do 14 | context 'when inclusion parameter not passing' do 15 | let(:params) { {} } 16 | 17 | it 'skips all steps' do 18 | expect(result['contract.uri_query']).to be_nil 19 | expect(result[:inclusion_options]).to be_nil 20 | expect(result).to be_success 21 | end 22 | end 23 | 24 | context 'when valid inclusion parameter passing' do 25 | it 'returns succesful result' do 26 | expect(result['contract.uri_query']).to be_present 27 | expect(result['result.contract.uri_query']).to be_success 28 | expect(result[:inclusion_options]).to eq([inclusion_params]) 29 | expect(result).to be_success 30 | end 31 | end 32 | end 33 | 34 | describe 'Failure' do 35 | include_examples 'nested inclusion errors' 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/lib/operation/perform_ordering_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Lib::Operation::PerformOrdering do 4 | subject(:operation) { described_class.call(params) } 5 | 6 | let(:params) { { order_options: order_options, relation: relation } } 7 | let(:relation) { User.all } 8 | 9 | describe 'Success' do 10 | context 'when ordering options not passed' do 11 | let(:order_options) { nil } 12 | 13 | it 'return success result' do 14 | expect(operation[:relation]).to eq(relation) 15 | expect(operation).to be_success 16 | end 17 | end 18 | 19 | context 'when order options is passed' do 20 | let(:order_options) { { name: :desc } } 21 | 22 | before { create_list(:user, 3) } 23 | 24 | it 'returns ordered relation' do 25 | expect(operation[:relation].to_a).to eq(User.all.order(name: :desc).to_a) 26 | expect(operation).to be_success 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/account/profiles/show_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Account::Profiles::Operation::Show do 4 | subject(:result) { described_class.call(params: params, current_account: account) } 5 | 6 | let(:account) { create(:account, :with_user) } 7 | let(:inclusion_params) { 'account' } 8 | let(:params) { { include: inclusion_params } } 9 | 10 | describe 'Success' do 11 | it 'prepares data for user profile rendering' do 12 | expect(Api::V1::Lib::Operation::Inclusion).to receive(:call).and_call_original 13 | expect(result[:model]).to eq(account.user) 14 | expect(result[:available_inclusion_options]).to eq(%w[account]) 15 | expect(result).to be_success 16 | end 17 | end 18 | 19 | describe 'Failure' do 20 | context 'when inclusion errors' do 21 | include_examples 'nested inclusion errors' 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/lib/operation/check_email_token_redis_equality_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Lib::Operation::CheckEmailTokenRedisEquality do 4 | subject(:result) { described_class.call(ctx) } 5 | 6 | let(:ctx) { { 'contract.default' => ApplicationContract.new({}), email_token: email_token } } 7 | let(:account) { create(:account) } 8 | let(:email_token) { create_token(:email, account: account) } 9 | let(:email_token1) { create_token(:email, account: account) } 10 | 11 | before { Api::V1::Users::Lib::Service::RedisAdapter.push_token(email_token) } 12 | 13 | describe '.call' do 14 | describe 'Success' do 15 | it 'check tokens equality' do 16 | expect(Api::V1::Users::Lib::Service::RedisAdapter).to receive(:find_token).with(email_token).and_call_original 17 | expect(result).to be_success 18 | end 19 | end 20 | 21 | describe 'Failure' do 22 | include_examples 'has email token equality errors' 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/lib/operation/decrypt_email_token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Lib::Operation::DecryptEmailToken do 4 | subject(:result) { described_class.call(params: params) } 5 | 6 | let(:account) { create(:account) } 7 | let(:email_token) { create_token(:email, account: account) } 8 | 9 | describe 'Success' do 10 | let(:params) { { email_token: email_token } } 11 | 12 | it 'decrypts email token, set model' do 13 | expect(Api::V1::Users::Lib::Service::EmailToken).to receive(:read).and_call_original 14 | expect(result[:model]).to be_persisted 15 | expect(result).to be_success 16 | end 17 | end 18 | 19 | describe 'Failure' do 20 | context 'when email token errors' do 21 | include_examples 'has email token decryption errors' 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/lib/service/email_token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Lib::Service::EmailToken do 4 | let(:account) { instance_double('Account', id: rand(1..42)) } 5 | let(:account_id) { account.id } 6 | let(:payload) { { account_id: account_id } } 7 | 8 | describe 'defined constants' do 9 | it { expect(described_class).to be_const_defined(:ERROR_MESSAGE) } 10 | it { expect(described_class).to be_const_defined(:TOKEN_LIFETIME) } 11 | it { expect(described_class::TOKEN_LIFETIME).to eq(24.hours) } 12 | end 13 | 14 | context 'when hmac secret exists' do 15 | describe '.create' do 16 | it 'creates email token from payload' do 17 | expect(described_class.create(payload)).to be_an_instance_of(String) 18 | end 19 | end 20 | 21 | describe '.read' do 22 | let(:token) { create_token(:email, account: account, namespace: :namespace) } 23 | 24 | context 'with valid token' do 25 | it 'includes token payload' do 26 | expect(described_class.read(token)).to include(account_id: account_id) 27 | expect(described_class.read(token)).to include(:exp) 28 | expect(described_class.read(token)).to include(:namespace) 29 | end 30 | end 31 | 32 | context 'with invalid token' do 33 | it 'returns false' do 34 | expect(described_class.read('invalid_token')).to be(false) 35 | end 36 | end 37 | end 38 | end 39 | 40 | context 'when hmac secret not exists' do 41 | let(:error_expectation) { raise_error(RuntimeError, /Secret key is not assigned/) } 42 | 43 | before { stub_const('Constants::Shared::HMAC_SECRET', nil) } 44 | 45 | it 'raises runtime error' do 46 | expect { described_class.create(payload) }.to error_expectation 47 | expect { described_class.read(payload) }.to error_expectation 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/lib/service/token_namespace_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Lib::Service::TokenNamespace do 4 | describe '.call' do 5 | subject(:service) { described_class.call(namespace, entity_id) } 6 | 7 | let(:namespace) { :namespace } 8 | let(:entity_id) { rand(1..42) } 9 | 10 | it 'returns interpolated token namespace' do 11 | expect(service).to eq("#{namespace}-#{entity_id}") 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/registrations/worker/email_confirmation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Registrations::Worker::EmailConfirmation, type: :worker do 4 | describe '.perform' do 5 | subject(:worker) do 6 | described_class.new.perform( 7 | email: email, 8 | token: token, 9 | user_verification_path: user_verification_path 10 | ) 11 | end 12 | 13 | let(:email) { FFaker::Internet.email } 14 | let(:token) { create_token(:email) } 15 | let(:user_verification_path) { FFaker::InternetSE.slug } 16 | let(:mailer_instance) { instance_double('MailMessage', :deliver_now) } 17 | 18 | it 'calls UserMailer' do 19 | expect(UserMailer) 20 | .to receive(:confirmation).with(email, token, user_verification_path).and_return(mailer_instance) 21 | expect(mailer_instance).to receive(:deliver_now) 22 | worker 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/reset_passwords/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::ResetPasswords::Operation::Create do 4 | subject(:result) { described_class.call(params: params) } 5 | 6 | let(:params) { {} } 7 | let(:email) { FFaker::Internet.email } 8 | 9 | before { create(:account, email: email) } 10 | 11 | describe 'Success' do 12 | let(:params) { { email: email } } 13 | 14 | it 'creates and email reset password token' do 15 | expect(Api::V1::Users::Lib::Service::EmailToken).to receive(:create).and_call_original 16 | expect(Api::V1::Users::Lib::Service::RedisAdapter).to receive(:push_token).and_call_original 17 | expect(Api::V1::Users::ResetPasswords::Worker::EmailResetPasswordUrl).to receive(:perform_async).and_call_original 18 | expect(result[:semantic_success]).to eq(:accepted) 19 | expect(result).to be_success 20 | end 21 | end 22 | 23 | describe 'Failure' do 24 | context 'without params' do 25 | let(:errors) { { email: [I18n.t('errors.key?')] } } 26 | 27 | include_examples 'has validation errors' 28 | end 29 | 30 | context 'with empty params' do 31 | let(:params) { { email: '' } } 32 | let(:errors) { { email: [I18n.t('errors.filled?')] } } 33 | 34 | include_examples 'has validation errors' 35 | end 36 | 37 | context 'with wrong params type' do 38 | let(:params) { { email: true } } 39 | let(:errors) { { email: [I18n.t('errors.str?')] } } 40 | 41 | include_examples 'has validation errors' 42 | end 43 | 44 | context 'when account not found' do 45 | let(:errors) { { email: [I18n.t('errors.reset_password.email_not_found')] } } 46 | let(:params) { { email: "_#{email}" } } 47 | 48 | include_examples 'has validation errors' 49 | 50 | it 'sets semantic failure not_found' do 51 | expect(result[:semantic_failure]).to eq(:not_found) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/reset_passwords/show_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::ResetPasswords::Operation::Show do 4 | subject(:result) { described_class.call(params: params) } 5 | 6 | let(:account) { create(:account) } 7 | let(:email_token) { create_token(:email, account: account) } 8 | let(:params) { { email_token: email_token } } 9 | 10 | describe 'Success' do 11 | before { Api::V1::Users::Lib::Service::RedisAdapter.push_token(email_token) } 12 | 13 | it 'decrypts email token, set model' do 14 | expect(Api::V1::Users::Lib::Operation::DecryptEmailToken).to receive(:call).and_call_original 15 | expect(Api::V1::Users::Lib::Operation::CheckEmailTokenRedisEquality).to receive(:call).and_call_original 16 | expect(result).to be_success 17 | end 18 | end 19 | 20 | describe 'Failure' do 21 | context 'when email token decryption errors' do 22 | include_examples 'has email token decryption errors' 23 | end 24 | 25 | context 'when email token redis equality errors' do 26 | include_examples 'has email token equality errors' 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/reset_passwords/worker/email_reset_password_url_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::ResetPasswords::Worker::EmailResetPasswordUrl, type: :worker do 4 | describe '.perform' do 5 | subject(:worker) do 6 | described_class.new.perform( 7 | email: email, 8 | token: token, 9 | user_reset_password_path: user_reset_password_path 10 | ) 11 | end 12 | 13 | let(:email) { FFaker::Internet.email } 14 | let(:token) { create_token(:email) } 15 | let(:user_reset_password_path) { FFaker::InternetSE.slug } 16 | let(:mailer_instance) { instance_double('MailMessage', :deliver_now) } 17 | 18 | it 'calls UserMailer' do 19 | expect(UserMailer) 20 | .to receive(:reset_password).with(email, token, user_reset_password_path).and_return(mailer_instance) 21 | expect(mailer_instance).to receive(:deliver_now) 22 | worker 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/sessions/destroy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Sessions::Operation::Destroy do 4 | subject(:result) { described_class.call(found_token: refresh_token) } 5 | 6 | describe 'Success' do 7 | let(:account) { create(:account) } 8 | let(:refresh_token) { create_token(:refresh, account: account) } 9 | 10 | it 'clears user session' do 11 | expect(Api::V1::Users::Lib::Service::SessionToken).to receive(:destroy).and_call_original 12 | expect(result[:semantic_success]).to eq(:destroyed) 13 | expect(result).to be_success 14 | end 15 | end 16 | 17 | describe 'Failure' do 18 | context 'with invalid refresh token' do 19 | let(:refresh_token) { 'invalid_token' } 20 | 21 | it do 22 | expect(result[:semantic_success]).to be_nil 23 | expect(result).to be_failure 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/sessions/refreshes/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Sessions::Refreshes::Operation::Create do 4 | subject(:result) { described_class.call(found_token: refresh_token, payload: payload) } 5 | 6 | let(:account) { create(:account) } 7 | let(:payload) { { 'account_id' => account.id } } 8 | 9 | describe 'Success' do 10 | let(:refresh_token) { create_token(:refresh, :expired, account: account) } 11 | 12 | it 'refreshes user session' do 13 | expect(Api::V1::Users::Lib::Service::SessionToken).to receive(:refresh).and_call_original 14 | expect(result[:tokens]).to include(:access, :access_expires_at, :csrf) 15 | expect(result[:semantic_success]).to eq(:created) 16 | expect(result[:renderer]).to include(:serializer, :meta) 17 | expect(result).to be_success 18 | end 19 | end 20 | 21 | describe 'Failure' do 22 | shared_examples 'operation fails' do 23 | it 'operation fails' do 24 | expect(result[:tokens]).to eq(tokens_expectation) 25 | expect(result[:semantic_failure]).to eq(:forbidden) 26 | expect(result).to be_failure 27 | end 28 | end 29 | 30 | context 'with invalid refresh token' do 31 | let(:refresh_token) { 'invalid_token' } 32 | let(:tokens_expectation) { nil } 33 | 34 | include_examples 'operation fails' 35 | end 36 | 37 | context 'with unexpired refresh token' do 38 | let(:refresh_token) { create_token(:refresh, :unexpired, account: account) } 39 | let(:tokens_expectation) { false } 40 | 41 | include_examples 'operation fails' 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/concepts/api/v1/users/verifications/show_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::Verifications::Operation::Show do 4 | subject(:result) { described_class.call(params: params) } 5 | 6 | let(:account) { create(:account) } 7 | let(:email_token) { create_token(:email, account: account) } 8 | let(:params) { { email_token: email_token } } 9 | 10 | describe 'Success' do 11 | it 'verifies user account' do 12 | expect(Api::V1::Users::Lib::Operation::DecryptEmailToken).to receive(:call).and_call_original 13 | expect(UserMailer).to receive(:verification_successful).with(email: account.email).and_call_original 14 | expect { result } 15 | .to change { account.reload.verified }.from(false).to(true) 16 | .and change { User.exists?(account_id: account.id) }.from(false).to(true) 17 | expect(result).to be_success 18 | end 19 | end 20 | 21 | describe 'Failure' do 22 | context 'when email token errors' do 23 | let(:params) { {} } 24 | 25 | include_examples 'has email token decryption errors' 26 | end 27 | 28 | context 'when user account was already verified' do 29 | let(:errors) { { base: [I18n.t('errors.verification.user_account_already_verified')] } } 30 | 31 | before { account.toggle!(:verified) } # rubocop:disable Rails/SkipsModelValidations 32 | 33 | include_examples 'has validation errors' 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/concepts/application_contract_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationContract do 4 | it { expect(described_class).to be < Reform::Form } 5 | it { expect(described_class.ancestors).to include(Reform::Form::Dry) } 6 | end 7 | -------------------------------------------------------------------------------- /spec/concepts/application_decorator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationDecorator do 4 | it { expect(described_class).to be < Draper::Decorator } 5 | end 6 | -------------------------------------------------------------------------------- /spec/concepts/application_operation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationOperation do 4 | it { expect(described_class).to be < Trailblazer::Operation } 5 | end 6 | -------------------------------------------------------------------------------- /spec/concepts/application_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationSerializer do 4 | it { expect(described_class.ancestors).to include(JSONAPI::Serializer) } 5 | end 6 | -------------------------------------------------------------------------------- /spec/concepts/application_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationWorker do 4 | it { expect(described_class.ancestors).to include(Sidekiq::Worker) } 5 | end 6 | -------------------------------------------------------------------------------- /spec/constants/token_namespace_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Constants::TokenNamespace do 4 | describe 'Constants::TokenNamespace::SESSION' do 5 | it { expect(described_class).to be_const_defined(:SESSION) } 6 | it { expect(described_class::SESSION).to eq('user-account') } 7 | end 8 | 9 | describe 'Constants::TokenNamespace::RESET_PASSWORD' do 10 | it { expect(described_class).to be_const_defined(:RESET_PASSWORD) } 11 | it { expect(described_class::RESET_PASSWORD).to eq('reset-password') } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/controllers/api/v1/users/sessions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Api::V1::Users::SessionsController, type: :controller do 4 | describe '#DELETE #destroy' do 5 | before { post :destroy } 6 | 7 | it { expect(response.body).to include('base', I18n.t('errors.session.invalid_token')) } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/controllers/api_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApiController, type: :controller do 4 | describe 'class settings' do 5 | it { expect(described_class).to be < ActionController::API } 6 | it { is_expected.to be_a(DefaultEndpoint) } 7 | it { is_expected.to be_a(Authentication) } 8 | it { is_expected.to be_a(JWTSessions::RailsAuthorization) } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/controllers/application_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationController, type: :controller do 4 | it { expect(described_class).to be < ActionController::Base } 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/accounts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :account do 5 | email { FFaker::Internet.unique.email } 6 | password { FFaker::Internet.password } 7 | password_confirmation { password } 8 | user { nil } 9 | 10 | trait :with_user do 11 | user 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/factories/admin_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :admin_user do 5 | email { FFaker::Internet.unique.email } 6 | password { FFaker::Internet.password } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :user do 5 | name { FFaker::Name.name } 6 | account 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/features/admin/admin_users/edit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Admin->Edit', type: :feature do 4 | let(:admin) { create(:admin_user) } 5 | let(:attributes) { attributes_for(:admin_user) } 6 | let(:ensure_sign_in_and_visit) do 7 | sign_in(admin) 8 | visit(edit_admin_admin_user_path(admin)) 9 | end 10 | 11 | before do 12 | ensure_sign_in_and_visit 13 | 14 | fill_in('admin_user[email]', with: admin.email) 15 | fill_in('admin_user[password]', with: attributes[:password]) 16 | fill_in('admin_user[password_confirmation]', with: attributes[:password]) 17 | click_button('commit') 18 | end 19 | 20 | it 'has log in with new password' do 21 | fill_in(I18n.t('active_admin.devise.email.title'), with: admin.email) 22 | fill_in(I18n.t('active_admin.devise.password.title'), with: attributes[:password]) 23 | click_button(I18n.t('active_admin.devise.login.title')) 24 | 25 | expect(page).to have_css('.flash_notice', text: I18n.t('devise.sessions.signed_in')) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/features/admin/admin_users/index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Admin->Index', type: :feature do 4 | let(:admin) { create(:admin_user) } 5 | let(:ensure_sign_in_and_visit) do 6 | sign_in(admin) 7 | visit(admin_admin_users_path) 8 | end 9 | 10 | before { ensure_sign_in_and_visit } 11 | 12 | it 'shows admins table' do 13 | within('#index_table_admin_users tbody') do 14 | expect(page).to have_css('tr', count: 1) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/features/admin/admin_users/show_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Admin->Show', type: :feature do 4 | let(:admin) { create(:admin_user) } 5 | let(:ensure_sign_in_and_visit) do 6 | sign_in(admin) 7 | visit(admin_admin_user_path(admin)) 8 | end 9 | 10 | before { ensure_sign_in_and_visit } 11 | 12 | it 'shows admin panel' do 13 | within("#attributes_table_admin_user_#{admin.id}") do 14 | expect(page).to have_css('.row-id', text: admin.id) 15 | expect(page).to have_css('.row-created_at', text: I18n.l(admin.created_at, format: :long)) 16 | expect(page).to have_css('.row-email', text: admin.email) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/features/admin/check_root_path_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'RootPath' do 4 | context 'when visit root path' do 5 | before { visit(root_path) } 6 | 7 | it 'has redirect to admin login page' do 8 | expect(page).to have_current_path(new_admin_user_session_path) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/features/admin/sign_in_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'SignIn' do 4 | before { visit(new_admin_user_session_path) } 5 | 6 | context 'with the valid email & password' do 7 | let(:admin) { create(:admin_user) } 8 | 9 | before do 10 | fill_in(I18n.t('active_admin.devise.email.title'), with: admin.email) 11 | fill_in(I18n.t('active_admin.devise.password.title'), with: admin.password) 12 | click_button(I18n.t('active_admin.devise.login.title')) 13 | end 14 | 15 | it 'signed in successfully' do 16 | expect(page).to have_css('.flash_notice', text: I18n.t('devise.sessions.signed_in')) 17 | end 18 | end 19 | 20 | context 'with the invalid email or password' do 21 | let(:admin) { attributes_for(:admin_user) } 22 | 23 | before do 24 | fill_in(I18n.t('active_admin.devise.email.title'), with: admin[:email]) 25 | fill_in(I18n.t('active_admin.devise.password.title'), with: admin[:password]) 26 | click_button(I18n.t('active_admin.devise.login.title')) 27 | end 28 | 29 | it 'shows error messages' do 30 | expect(page).to have_css('.flash_alert', text: I18n.t('devise.failure.invalid', authentication_keys: 'Email')) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/features/admin/sign_out_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'SignOut' do 4 | let(:admin) { create(:admin_user) } 5 | let(:ensure_sign_in_and_visit) do 6 | sign_in(admin) 7 | visit(edit_admin_admin_user_path(admin)) 8 | end 9 | 10 | before { ensure_sign_in_and_visit } 11 | 12 | context 'when a logged in user log out' do 13 | before do 14 | within('#utility_nav') { click_link(I18n.t('active_admin.logout')) } 15 | end 16 | 17 | it 'sign out successfully' do 18 | expect(page).to have_css('#admin_user_email_input', text: I18n.t('active_admin.devise.email.title')) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/jobs/application_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationJob do 4 | it { expect(described_class).to be < ActiveJob::Base } 5 | end 6 | -------------------------------------------------------------------------------- /spec/lib/macro/assign_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Macro do 4 | describe '.Assign' do 5 | subject(:result) { described_class::Assign(**params)[:task].call(ctx, **flow_options) } 6 | 7 | let(:params) { {} } 8 | 9 | let(:object) { instance_double('SomeObject', some_method: 'method context') } 10 | let(:ctx) { { object: object } } 11 | let(:flow_options) { { options: [] } } 12 | 13 | context 'when path passed' do 14 | let(:to) { :model } 15 | let(:params) { { to: to, path: %i[object some_method] } } 16 | 17 | it 'assigns object.some_method context into specified ctx key' do 18 | result 19 | expect(ctx[to]).to eq(object.some_method) 20 | end 21 | end 22 | 23 | context 'when value passed' do 24 | let(:to) { :model } 25 | let(:value) { :value } 26 | let(:params) { { to: to, path: %i[object some_method], value: value } } 27 | 28 | it 'assigns specified value into specified ctx key' do 29 | result 30 | expect(ctx[to]).to eq(value) 31 | end 32 | end 33 | 34 | context 'when try is set up' do 35 | let(:to) { :model } 36 | let(:object) { nil } 37 | let(:params) { { to: to, path: %i[object not_existing_method], try: true } } 38 | 39 | it 'uses safe call' do 40 | expect(object).to receive(:try).with(:not_existing_method).and_call_original 41 | result 42 | expect(ctx[to]).to be_nil 43 | end 44 | end 45 | 46 | it 'has uniqueness id' do 47 | params = { to: :path } 48 | expect(described_class::Assign(params)[:id]).not_to eq(described_class::Assign(params)[:id]) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/lib/macro/inject_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Macro do 4 | describe '.Inject' do 5 | subject(:result) { described_class::Inject(**deps)[:task].call(ctx, **flow_options) } 6 | 7 | let(:ctx) { {} } 8 | let(:deps) { { service: dependency } } 9 | let(:dependency_class) { 'DependencyClass' } 10 | let(:flow_options) { { options: [] } } 11 | 12 | context 'when passed string as value' do 13 | let(:dependency) { 'service.name' } 14 | 15 | it 'sets to context dependencies through dry container' do 16 | expect(Container).to receive(:[]).with(dependency).and_return(dependency_class) 17 | expect { result } 18 | .to change { ctx[:service] } 19 | .from(nil) 20 | .to(dependency_class) 21 | end 22 | end 23 | 24 | context 'when passed not string as value' do 25 | let(:dependency) { :service_name } 26 | 27 | it 'sets to context dependencies directly from value' do 28 | expect { result } 29 | .to change { ctx[:service] } 30 | .from(nil) 31 | .to(dependency) 32 | end 33 | end 34 | 35 | describe 'macro id' do 36 | it 'has formatted id' do 37 | expect(described_class::Inject({})[:id]).to macro_id_with('inject') 38 | end 39 | 40 | it 'has uniqueness id' do 41 | expect(described_class::Inject()[:id]).not_to eq(described_class::Inject()[:id]) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/lib/macro/semantic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Macro do 4 | describe '.Semantic' do 5 | subject(:result) { operation.call({}) } 6 | 7 | shared_examples 'sets semantic into context' do 8 | it { expect(result[semantic_marker]).to eq(:semantic_context) } 9 | end 10 | 11 | context 'when success value passed' do 12 | let(:semantic_marker) { :semantic_success } 13 | let(:operation) do 14 | Class.new(Trailblazer::Operation) do 15 | step Macro::Semantic(success: :semantic_context) 16 | end 17 | end 18 | 19 | include_examples 'sets semantic into context' 20 | end 21 | 22 | context 'when failure value passed' do 23 | let(:semantic_marker) { :semantic_failure } 24 | let(:operation) do 25 | Class.new(Trailblazer::Operation) do 26 | step Macro::Semantic(failure: :semantic_context) 27 | end 28 | end 29 | 30 | include_examples 'sets semantic into context' 31 | end 32 | 33 | describe 'macro id' do 34 | it 'has formatted id' do 35 | expect(described_class::Semantic({})[:id]).to macro_id_with('semantic') 36 | end 37 | 38 | it 'has uniqueness id' do 39 | expect(described_class::Semantic()[:id]).not_to eq(described_class::Semantic()[:id]) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/lib/service/json_api/hash_error_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Service::JsonApi::HashErrorSerializer do 4 | describe 'class settings' do 5 | it { expect(described_class).to be < Service::JsonApi::BaseErrorSerializer } 6 | end 7 | 8 | describe '.call' do 9 | subject(:service) { described_class.call(error_data_structure) } 10 | 11 | include_examples 'error data structure' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/lib/service/json_api/paginator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Service::JsonApi::Paginator do 4 | subject(:paginator) { described_class.call(resource_path: resource_path, pagy: pagy) } 5 | 6 | describe 'defined constants' do 7 | it { expect(described_class).to be_const_defined(:QUERY_PAGE_PARAMETER) } 8 | end 9 | 10 | describe '.call' do 11 | let(:resource_path) { 'resource_path' } 12 | let(:query_page_parameter) { Service::JsonApi::Paginator::QUERY_PAGE_PARAMETER } 13 | 14 | context 'when only 1 page available' do 15 | let(:pagy) { OpenStruct.new(page: 1, next: nil, prev: nil, pages: 1) } 16 | 17 | it 'returns object with page links' do 18 | expect(paginator).to eq( 19 | self: "#{resource_path}?#{query_page_parameter}=1", 20 | first: resource_path, 21 | next: nil, 22 | prev: nil, 23 | last: "#{resource_path}?#{query_page_parameter}=1" 24 | ) 25 | end 26 | end 27 | 28 | context 'when has several pages' do 29 | let(:pagy) { OpenStruct.new(page: 2, next: 3, prev: 1, pages: 3) } 30 | 31 | it 'returns object with page links' do 32 | expect(paginator).to eq( 33 | self: "#{resource_path}?#{query_page_parameter}=2", 34 | first: resource_path, 35 | next: "#{resource_path}?#{query_page_parameter}=3", 36 | prev: "#{resource_path}?#{query_page_parameter}=1", 37 | last: "#{resource_path}?#{query_page_parameter}=3" 38 | ) 39 | end 40 | end 41 | 42 | context 'when resource path not passed' do 43 | let(:pagy) { instance_double('Pagy', blank?: true) } 44 | 45 | it 'sets nill for blank pages' do 46 | %i[page next prev pages].each { |method| allow(pagy).to receive(method) } 47 | expect(paginator.compact).to eq(first: resource_path) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/lib/service/json_api/resource_error_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Service::JsonApi::ResourceErrorSerializer do 4 | describe 'class settings' do 5 | it { expect(described_class).to be < Service::JsonApi::BaseErrorSerializer } 6 | it { expect(described_class::ERRORS_SOURCES).to eq(Service::JsonApi::BaseErrorSerializer::ERRORS_SOURCES) } 7 | end 8 | 9 | describe '.call' do 10 | subject(:service) { described_class.call('contract.default' => contract) } 11 | 12 | let(:contract) { instance_double('Contract') } 13 | 14 | before { allow(contract).to receive_message_chain(:errors, :messages) { error_data_structure } } 15 | 16 | include_examples 'error data structure' 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/lib/service/json_api/resource_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Service::JsonApi::ResourceSerializer do 4 | describe '.call' do 5 | class TestSerializer # rubocop:disable RSpec/LeakyConstantDeclaration 6 | include JSONAPI::Serializer 7 | set_type :test_object 8 | attributes :test_attribute 9 | end 10 | 11 | subject(:jsonapi_serializer) { described_class.call(result_instance) } 12 | 13 | let(:test_object) { instance_double('TestObject', id: 1, test_attribute: 'test_attribute') } 14 | let(:result_instance) do 15 | { 16 | renderer: 17 | { 18 | serializer: TestSerializer, 19 | meta: 'meta' 20 | }, 21 | model: test_object 22 | } 23 | end 24 | 25 | it 'return test serializer instance' do 26 | expect(jsonapi_serializer).to be_an_instance_of(TestSerializer) 27 | expect(jsonapi_serializer.to_hash).to include( 28 | data: 29 | { 30 | attributes: { test_attribute: 'test_attribute' }, 31 | id: '1', 32 | type: :test_object 33 | }, 34 | meta: 'meta' 35 | ) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/lib/service/pagy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Service::Pagy do 4 | subject(:pagy_service) { described_class.call(collection, call_options) } 5 | 6 | let(:call_options) { { **params } } 7 | let(:params) { { page: 2, items: 1 } } 8 | 9 | context 'when active relation collection' do 10 | let(:collection) { Set.new(%w[1 2]) } 11 | let(:pagy_method) { :pagy } 12 | 13 | it 'prepares params class variable and calls pagy' do 14 | expect(described_class).to receive(pagy_method).with(collection, params) 15 | pagy_service 16 | end 17 | end 18 | 19 | context 'when array collection' do 20 | let(:collection) { %w[1 2] } 21 | let(:pagy_method) { :pagy_array } 22 | 23 | it 'prepares params class variable and calls pagy' do 24 | expect(described_class).to receive(pagy_method).with(collection, params) 25 | pagy_service 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/mailers/application_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationMailer, type: :mailer do 4 | it { expect(described_class).to be < ActionMailer::Base } 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/account_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Account, type: :model do 4 | describe 'fields' do 5 | it { is_expected.to have_db_column(:email).of_type(:string) } 6 | it { is_expected.to have_db_column(:password_digest).of_type(:string) } 7 | it { is_expected.to have_db_column(:verified).of_type(:boolean).with_options(default: false) } 8 | it { is_expected.to have_db_column(:created_at).of_type(:datetime) } 9 | it { is_expected.to have_db_column(:updated_at).of_type(:datetime) } 10 | end 11 | 12 | describe 'model relations' do 13 | it { is_expected.to have_one(:user).dependent(:destroy) } 14 | end 15 | 16 | it { is_expected.to have_secure_password } 17 | end 18 | -------------------------------------------------------------------------------- /spec/models/application_record_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationRecord, type: :model do 4 | it { expect(described_class).to be < ActiveRecord::Base } 5 | it { expect(described_class.abstract_class).to be(true) } 6 | end 7 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe User, type: :model do 4 | describe 'model fields' do 5 | it { is_expected.to have_db_column(:name).of_type(:string) } 6 | it { is_expected.to have_db_column(:created_at).of_type(:datetime) } 7 | it { is_expected.to have_db_column(:updated_at).of_type(:datetime) } 8 | end 9 | 10 | describe 'model relations' do 11 | it { is_expected.to belong_to(:account) } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'spec_helper' 5 | 6 | ENV['RAILS_ENV'] ||= 'test' 7 | require File.expand_path('../config/environment', __dir__) 8 | 9 | %w[api_doc support].each do |dir| 10 | Dir[Rails.root.join('spec', dir, '**', '*.rb')].sort.each do |file| 11 | require file unless file[/\A.+_spec\.rb\z/] 12 | end 13 | end 14 | 15 | abort('The Rails environment is running in production mode!') if Rails.env.production? 16 | 17 | require 'rspec/rails' 18 | 19 | begin 20 | ActiveRecord::Migration.maintain_test_schema! 21 | rescue ActiveRecord::PendingMigrationError => e 22 | puts e.to_s.strip 23 | exit 1 24 | end 25 | 26 | Rails.application.load_tasks 27 | Rake::Task['factory_bot:lint'].invoke 28 | 29 | RSpec.configure do |config| 30 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 31 | config.use_transactional_fixtures = true 32 | config.infer_spec_type_from_file_location! 33 | config.filter_rails_from_backtrace! 34 | config.infer_base_class_for_anonymous_controllers = false 35 | config.mock_with :rspec do |mocks| 36 | mocks.allow_message_expectations_on_nil = true 37 | end 38 | 39 | config.include FactoryBot::Syntax::Methods 40 | config.include ActiveSupport::Testing::TimeHelpers 41 | config.include Devise::Test::IntegrationHelpers, type: :feature 42 | config.include Warden::Test::Helpers, type: :feature 43 | config.include Helpers::RootHelpers 44 | config.include Helpers::OperationHelpers, type: :operation 45 | config.include Helpers::RequestHelpers, type: :request 46 | end 47 | 48 | require 'swagger_helper' 49 | -------------------------------------------------------------------------------- /spec/requests/api/v1/users/session/refreshes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Api::V1::Users::Session::Refreshes', type: :request do 4 | let(:account) { create(:account) } 5 | 6 | describe 'POST #create' do 7 | path '/api/v1/users/session/refresh' do 8 | post('create refresh') do 9 | tags 'Users' 10 | consumes 'application/json' 11 | parameter name: :'X-Refresh-Token', in: :header, type: :string 12 | 13 | response(403, 'when unexpired access token') do 14 | let(:'X-Refresh-Token') { create_token(:refresh, :unexpired, account: account) } 15 | 16 | run_test! do 17 | expect(response).to be_forbidden 18 | expect(response.body).to be_empty 19 | end 20 | end 21 | 22 | response(401, 'when user unauthenticated') do 23 | let(:'X-Refresh-Token') { nil } 24 | 25 | include_examples 'renders unauthenticated errors' 26 | end 27 | 28 | response(201, 'successful renders refreshed tokens bundle in meta') do 29 | let(:'X-Refresh-Token') { create_token(:refresh, :expired, account: account) } 30 | 31 | run_test! do 32 | expect(response).to be_created 33 | expect(response).to match_json_schema('v1/users/session/refresh/create') 34 | end 35 | end 36 | end 37 | end 38 | 39 | describe 'N+1', :n_plus_one do 40 | let(:headers) { { 'X-Refresh-Token': refresh_token } } 41 | let(:refresh_token) { create_token(:refresh, :expired, account: account) } 42 | 43 | populate { |n| create_list(:account, n) } 44 | 45 | specify do 46 | expect { post '/api/v1/users/session/refresh', headers: headers, as: :json } 47 | .to perform_constant_number_of_queries 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/requests/api/v1/users/verifications_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Api::V1::Users::Verifications', type: :request do 4 | let(:account) { create(:account) } 5 | let(:email_token) { create_token(:email, account: account) } 6 | let(:confirmation_url) { "/api/v1/users/verification?email_token=#{email_token}" } 7 | 8 | describe 'GET #show' do 9 | path '/api/v1/users/verification?email_token={email_token}' do 10 | get('show verification') do 11 | tags 'Users' 12 | produces 'application/json' 13 | parameter name: :email_token, in: :path, type: :string 14 | 15 | response(404, 'when user account not found') do 16 | let(:not_exising_account) { instance_double('Account', id: account.id.next) } 17 | let(:email_token) { create_token(:email, account: not_exising_account) } 18 | 19 | include_examples 'returns not found status' 20 | end 21 | 22 | response(422, 'when wrong email token') do 23 | let(:email_token) { 'invalid_token' } 24 | 25 | include_examples 'renders unprocessable entity errors' 26 | end 27 | 28 | response(204, 'successful verifies user account') do 29 | run_test! 30 | end 31 | end 32 | end 33 | 34 | describe 'N+1', :n_plus_one do 35 | before { get confirmation_url } 36 | 37 | populate { |n| create_list(:account, n) } 38 | 39 | specify do 40 | expect { get confirmation_url }.to perform_constant_number_of_queries 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.expect_with :rspec do |expectations| 5 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 6 | end 7 | 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | 12 | config.shared_context_metadata_behavior = :apply_to_host_groups 13 | config.filter_run_when_matching :focus 14 | config.example_status_persistence_file_path = 'spec/examples.txt' 15 | config.disable_monkey_patching! 16 | config.default_formatter = 'doc' if config.files_to_run.one? 17 | config.profile_examples = 10 18 | config.order = :random 19 | 20 | Kernel.srand config.seed 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/config/capybara.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'capybara/rails' 4 | require 'capybara/rspec' 5 | require 'webdrivers/chromedriver' 6 | 7 | Capybara.register_driver(:chrome) do |app| 8 | capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( 9 | # Enables access to logs with `page.driver.manage.get_log(:browser)` 10 | loggingPrefs: { 11 | browser: 'ALL', 12 | client: 'ALL', 13 | driver: 'ALL', 14 | server: 'ALL' 15 | } 16 | ) 17 | 18 | options = Selenium::WebDriver::Chrome::Options.new 19 | options.add_argument('window-size=1920,1080') 20 | 21 | # Run headless by default unless CHROME_HEADLESS specified 22 | options.add_argument('headless') unless /^(false|no|0)$/.match?(ENV['CHROME_HEADLESS']) 23 | 24 | Capybara::Selenium::Driver.new( 25 | app, 26 | browser: :chrome, 27 | desired_capabilities: capabilities, 28 | options: options 29 | ) 30 | end 31 | 32 | Capybara.configure do |config| 33 | config.default_driver = :chrome 34 | config.javascript_driver = :chrome 35 | config.server = :puma, { Silent: true } 36 | end 37 | -------------------------------------------------------------------------------- /spec/support/config/ffaker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.before(:all) { FFaker::Random.seed = config.seed } 5 | config.before { FFaker::Random.reset! } 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/config/json_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | JsonMatchers.schema_root = 'spec/support/schemas' 4 | -------------------------------------------------------------------------------- /spec/support/config/n_plus_one_control.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'n_plus_one_control/rspec' 4 | -------------------------------------------------------------------------------- /spec/support/config/shoulda_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Shoulda::Matchers.configure do |config| 4 | config.integrate do |with| 5 | with.test_framework :rspec 6 | with.library :rails 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/config/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Sidekiq.configure do |config| 4 | config.clear_all_enqueued_jobs = true 5 | config.enable_terminal_colours = true 6 | config.warn_when_jobs_not_processed_by_sidekiq = false 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/helpers/operation_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | module OperationHelpers 5 | def create_available_columns(*columns) 6 | Api::V1::Lib::Service::JsonApi::ColumnsBuilder.call(*columns) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/helpers/request_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | module RequestHelpers 5 | def auth_header(account) 6 | "Bearer #{create_token(:access, account: account)}" 7 | end 8 | 9 | def resource_type(response) 10 | body = JSON.parse(response.body) 11 | dig_params = body['data'].is_a?(Array) ? ['data', 0, 'type'] : %w[data type] 12 | body.dig(*dig_params) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/helpers/root_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | module RootHelpers 5 | # :reek:TooManyStatements 6 | def create_token(type, access_token = :unexpired, account: nil, exp: nil, namespace: nil) # rubocop:disable Metrics/AbcSize 7 | payload, current_time = { account_id: account&.id || rand(1..42), namespace: namespace }, Time.now.to_i 8 | 9 | time = 10 | if access_token.eql?(:expired) 11 | allow(JWTSessions).to receive(:access_exp_time).and_return(0) 12 | current_time - 10 13 | else 14 | exp || 24.hours.from_now.to_i 15 | end 16 | 17 | return JWT.encode(payload.merge(exp: time), Constants::Shared::HMAC_SECRET) if type.eql?(:email) 18 | 19 | JWTSessions::Session.new( 20 | access_claims: { exp: current_time }, 21 | payload: payload 22 | ).login[type] 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/matchers/macro_id_with.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define(:macro_id_with) do |macro_name| 4 | match { |macro_id| /\Amacro\/#{macro_name}_id_\d+\z/.match?(macro_id) } 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/matchers/not_change.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define_negated_matcher :not_change, :change 4 | -------------------------------------------------------------------------------- /spec/support/schemas/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": {}, 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "$id": "http://example.com/errors.json", 5 | "type": "object", 6 | "title": "The Errors Schema", 7 | "required": [ 8 | "errors" 9 | ], 10 | "properties": { 11 | "errors": { 12 | "$id": "#/properties/errors", 13 | "type": "array", 14 | "title": "The Errors Schema", 15 | "items": { 16 | "$id": "#/properties/errors/items", 17 | "type": "object", 18 | "title": "The Items Schema", 19 | "required": [ 20 | "detail", 21 | "source" 22 | ], 23 | "properties": { 24 | "detail": { 25 | "$id": "#/properties/errors/items/properties/detail", 26 | "type": "string", 27 | "title": "The Detail Schema", 28 | "default": "", 29 | "examples": [ 30 | "must be filled" 31 | ], 32 | "pattern": "^(.*)$" 33 | }, 34 | "source": { 35 | "$id": "#/properties/errors/items/properties/source", 36 | "type": "object", 37 | "title": "The Source Schema", 38 | "required": [ 39 | "pointer" 40 | ], 41 | "properties": { 42 | "pointer": { 43 | "$id": "#/properties/errors/items/properties/source/properties/pointer", 44 | "type": "string", 45 | "title": "The Pointer Schema", 46 | "default": "", 47 | "examples": [ 48 | "/data/attributes/first-name" 49 | ], 50 | "pattern": "^(.*)$" 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /spec/support/shared_examples/operation/has_email_token_decryption_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'has email token decryption errors' do 4 | context 'without params' do 5 | let(:params) { {} } 6 | let(:errors) { { email_token: [I18n.t('errors.key?')] } } 7 | 8 | include_examples 'has validation errors' 9 | end 10 | 11 | context 'with empty params' do 12 | let(:params) { { email_token: '' } } 13 | let(:errors) { { email_token: [I18n.t('errors.filled?')] } } 14 | 15 | include_examples 'has validation errors' 16 | end 17 | 18 | context 'with wrong params type' do 19 | let(:params) { { email_token: true } } 20 | let(:errors) { { email_token: [I18n.t('errors.str?')] } } 21 | 22 | include_examples 'has validation errors' 23 | end 24 | 25 | context 'with invalid email token' do 26 | let(:params) { { email_token: 'invalid_token' } } 27 | let(:errors) { { base: [I18n.t('errors.verification.invalid_email_token')] } } 28 | 29 | include_examples 'has validation errors' 30 | end 31 | 32 | context 'when account not found' do 33 | let(:params) { { email_token: email_token } } 34 | 35 | before { account.destroy } 36 | 37 | it 'sets semantic failure not_found' do 38 | expect(result[:semantic_failure]).to eq(:not_found) 39 | expect(result).to be_failure 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/shared_examples/operation/has_email_token_equality_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'has email token equality errors' do 4 | shared_examples 'email token equality errors' do 5 | let(:errors) { { base: [I18n.t('errors.email_token.already_used')] } } 6 | 7 | include_examples 'has validation errors' 8 | end 9 | 10 | context 'when email token not eql email token in redis' do 11 | before do 12 | Api::V1::Users::Lib::Service::RedisAdapter.push_token( 13 | create_token(:email, account: account, exp: 1.hour.from_now.to_i) 14 | ) 15 | end 16 | 17 | it_behaves_like 'email token equality errors' 18 | end 19 | 20 | context 'when email token not exists in redis' do 21 | before { Api::V1::Users::Lib::Service::RedisAdapter.delete_token(email_token) } 22 | 23 | it_behaves_like 'email token equality errors' 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/shared_examples/operation/has_validation_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'has validation errors' do |namespace: :default| 4 | it 'has validation errors' do 5 | expect(result["contract.#{namespace}"].errors.messages).to eq(errors) 6 | expect(result).to be_failure 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/shared_examples/operation/nested_inclusion_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'nested inclusion errors' do 4 | shared_examples 'not successful operation' do 5 | it 'sets inclusion validation errors' do 6 | expect(result['contract.uri_query'].errors.messages).to eq(errors) 7 | expect(result).to be_failure 8 | end 9 | end 10 | 11 | context 'with invalid inclusion parameter' do 12 | let(:inclusion_params) { 'invalid_inclusion_parameter' } 13 | let(:errors) { { include: [I18n.t('errors.inclusion_params_valid?')] } } 14 | 15 | include_examples 'not successful operation' 16 | end 17 | 18 | context 'with not uniq inclusion parameters' do 19 | let(:params) { { include: "#{inclusion_params},#{inclusion_params}" } } 20 | let(:errors) { { include: [I18n.t('errors.inclusion_params_uniq?')] } } 21 | 22 | include_examples 'not successful operation' 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/shared_examples/request/renders_bad_request_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'renders bad_request errors' do 4 | run_test! do |response| 5 | expect(response).to be_bad_request 6 | expect(response).to match_json_schema('errors') 7 | expect(JSON.parse(response.body)).to match_array(bad_request_error) if defined? bad_request_error 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/shared_examples/request/renders_unauthenticated_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'renders unauthenticated errors' do 4 | run_test! do 5 | expect(response).to be_unauthorized 6 | expect(response).to match_json_schema('errors') 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/shared_examples/request/renders_unprocessable_entity_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'renders unprocessable entity errors' do 4 | run_test! do |response| 5 | expect(response).to be_unprocessable 6 | expect(response).to match_json_schema('errors') 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/shared_examples/request/renders_uri_query_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'renders uri query errors' do 4 | run_test! do |response| 5 | expect(response).to be_bad_request 6 | expect(response).to match_json_schema('errors') 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/shared_examples/request/returns_not_found_status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'returns not found status' do 4 | run_test! do |response| 5 | expect(response).to be_not_found 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/swagger_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | RSpec.configure do |config| 5 | # Specify a root folder where Swagger JSON files are generated 6 | # NOTE: If you're using the rswag-api to serve API descriptions, you'll need 7 | # to ensure that it's configured to serve Swagger from the same folder 8 | config.swagger_root = Rails.root.join('swagger').to_s 9 | 10 | # Define one or more Swagger documents and provide global metadata for each one 11 | # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will 12 | # be generated at the provided relative path under swagger_root 13 | # By default, the operations defined in spec files are added to the first 14 | # document below. You can override this behavior by adding a swagger_doc tag to the 15 | # the root example_group in your specs, e.g. describe '...', swagger_doc: 'v2/swagger.json' 16 | config.swagger_docs = { 17 | 'v1/swagger.yaml' => { 18 | openapi: '3.0.1', 19 | info: { 20 | title: 'API V1', 21 | version: 'v1' 22 | }, 23 | paths: {}, 24 | servers: [ 25 | { 26 | url: 'https://{defaultHost}', 27 | variables: { 28 | defaultHost: { 29 | default: 'localhost:3000' 30 | } 31 | } 32 | } 33 | ] 34 | } 35 | } 36 | 37 | # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. 38 | # The swagger_docs configuration option has the filename including format in 39 | # the key, this may want to be changed to avoid putting yaml in json files. 40 | # Defaults to json. Accepts ':json' and ':yaml'. 41 | config.swagger_format = :yaml 42 | end 43 | -------------------------------------------------------------------------------- /spec/uploaders/application_uploader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationUploader do 4 | it { expect(described_class).to be < Shrine } 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/application_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationWorker, type: :worker do 4 | it { expect(described_class.included_modules).to include(Sidekiq::Worker) } 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/sentry_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SentryWorker, type: :worker do 4 | subject(:worker) { described_class.new } 5 | 6 | let(:event) { 'event' } 7 | 8 | it 'adds job to a queue' do 9 | described_class.perform_async(event) 10 | expect(described_class).to have_enqueued_sidekiq_job(event) 11 | end 12 | 13 | it 'calls Raven.send_event with the correct arguments' do 14 | allow(Raven).to receive(:send_event) 15 | expect(Raven).to receive(:send_event).with(event) 16 | worker.perform(event) 17 | end 18 | 19 | it { is_expected.to be_processed_in('default') } 20 | it { is_expected.to be_retryable(true) } 21 | end 22 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/storage/.keep -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/tmp/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubygarage/boilerplate/8edf94f693f08dc8ca83a35dfb6156cbe176d6f9/vendor/.keep --------------------------------------------------------------------------------