├── .github └── workflows │ ├── docs.yml │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── OVERVIEW.md ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── exe └── rage ├── lib ├── rage-rb.rb ├── rage.rb └── rage │ ├── all.rb │ ├── application.rb │ ├── cable │ ├── adapters │ │ ├── base.rb │ │ └── redis.rb │ ├── cable.rb │ ├── channel.rb │ ├── connection.rb │ ├── protocols │ │ ├── actioncable_v1_json.rb │ │ ├── base.rb │ │ └── raw_web_socket_json.rb │ └── router.rb │ ├── cli.rb │ ├── code_loader.rb │ ├── configuration.rb │ ├── controller │ └── api.rb │ ├── cookies.rb │ ├── env.rb │ ├── errors.rb │ ├── ext │ ├── active_record │ │ └── connection_pool.rb │ └── setup.rb │ ├── fiber.rb │ ├── fiber_scheduler.rb │ ├── hooks.rb │ ├── logger │ ├── json_formatter.rb │ ├── logger.rb │ └── text_formatter.rb │ ├── middleware │ ├── cors.rb │ ├── fiber_wrapper.rb │ ├── origin_validator.rb │ ├── reloader.rb │ └── request_id.rb │ ├── openapi │ ├── builder.rb │ ├── collector.rb │ ├── converter.rb │ ├── index.html.erb │ ├── nodes │ │ ├── method.rb │ │ ├── parent.rb │ │ └── root.rb │ ├── openapi.rb │ ├── parser.rb │ └── parsers │ │ ├── ext │ │ ├── active_record.rb │ │ └── alba.rb │ │ ├── request.rb │ │ ├── response.rb │ │ ├── shared_reference.rb │ │ └── yaml.rb │ ├── params_parser.rb │ ├── rails.rb │ ├── request.rb │ ├── response.rb │ ├── router │ ├── README.md │ ├── backend.rb │ ├── constrainer.rb │ ├── dsl.rb │ ├── dsl_plugins │ │ ├── controller_action_options.rb │ │ ├── legacy_hash_notation.rb │ │ ├── legacy_root_notation.rb │ │ └── named_route_helpers.rb │ ├── handler_storage.rb │ ├── node.rb │ ├── strategies │ │ └── host.rb │ └── util.rb │ ├── rspec.rb │ ├── session.rb │ ├── setup.rb │ ├── sidekiq_session.rb │ ├── tasks.rb │ ├── templates │ ├── Gemfile │ ├── Rakefile │ ├── app-controllers-application_controller.rb │ ├── config-application.rb │ ├── config-environments-development.rb │ ├── config-environments-production.rb │ ├── config-environments-test.rb │ ├── config-initializers-.keep │ ├── config-routes.rb │ ├── config.ru │ ├── controller-template │ │ └── controller.rb │ ├── db-templates │ │ ├── app-models-application_record.rb │ │ ├── db-seeds.rb │ │ ├── mysql │ │ │ └── config-database.yml │ │ ├── postgresql │ │ │ └── config-database.yml │ │ ├── sqlite3 │ │ │ └── config-database.yml │ │ └── trilogy │ │ │ └── config-database.yml │ ├── lib-.keep │ ├── lib-tasks-.keep │ ├── log-.keep │ ├── model-template │ │ └── model.rb │ └── public-.keep │ ├── uploaded_file.rb │ └── version.rb ├── rage.gemspec └── spec ├── cable ├── adapters │ └── redis_spec.rb ├── channel │ ├── actions_spec.rb │ ├── attributes_spec.rb │ ├── data_spec.rb │ ├── identified_by_spec.rb │ ├── rescue_from_spec.rb │ ├── subscribe_spec.rb │ └── unsubscribe_spec.rb ├── connection_spec.rb └── router_spec.rb ├── code_loader_spec.rb ├── controller └── api │ ├── after_actions_spec.rb │ ├── around_actions_spec.rb │ ├── authenticate_spec.rb │ ├── before_actions_spec.rb │ ├── conditional_around_actions_spec.rb │ ├── conditional_before_actions_spec.rb │ ├── conditional_get_spec.rb │ ├── cookies_spec.rb │ ├── double_render_spec.rb │ ├── headers_spec.rb │ ├── render_spec.rb │ ├── rescue_from_spec.rb │ ├── session_spec.rb │ ├── skip_before_actions_inheritance_spec.rb │ ├── skip_before_actions_spec.rb │ └── wrap_parameters_spec.rb ├── cors_middleware_spec.rb ├── env_spec.rb ├── fiber_scheduler_spec.rb ├── fiber_spec.rb ├── fixtures ├── 10kb.txt ├── 2kb.txt └── 700b.txt ├── integration ├── cable_redis_spec.rb ├── file_server_spec.rb ├── integration_spec.rb ├── request_id_spec.rb ├── test_app │ ├── Gemfile │ ├── app │ │ ├── channels │ │ │ ├── multiply_numbers_channel.rb │ │ │ ├── rage_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ │ └── time_channel.rb │ │ ├── controllers │ │ │ ├── api │ │ │ │ ├── base_controller.rb │ │ │ │ ├── v1 │ │ │ │ │ └── users_controller.rb │ │ │ │ ├── v2 │ │ │ │ │ └── users_controller.rb │ │ │ │ └── v3 │ │ │ │ │ └── users_controller.rb │ │ │ ├── application_controller.rb │ │ │ ├── async_controller.rb │ │ │ ├── before_actions_controller.rb │ │ │ ├── logs_controller.rb │ │ │ └── params_controller.rb │ │ └── resources │ │ │ ├── api │ │ │ └── v1 │ │ │ │ ├── avatar_resource.rb │ │ │ │ └── user_resource.rb │ │ │ ├── base_user_resource.rb │ │ │ ├── comment_resource.rb │ │ │ └── user_resource.rb │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── cable.yml │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ └── alba.rb │ │ ├── openapi_components.yml │ │ └── routes.rb │ ├── lib │ │ └── .keep │ ├── log │ │ └── .keep │ └── public │ │ └── test.txt └── websockets │ ├── actioncable_v1_json_spec.rb │ └── raw_web_socket_json_spec.rb ├── logger_spec.rb ├── middleware └── request_id_spec.rb ├── multi_application_spec.rb ├── openapi ├── builder │ ├── auth_spec.rb │ ├── base_spec.rb │ ├── deprecated_spec.rb │ ├── description_spec.rb │ ├── internal_spec.rb │ ├── params_spec.rb │ ├── private_spec.rb │ ├── request_spec.rb │ ├── response_spec.rb │ ├── summary_spec.rb │ ├── tag_resolver_spec.rb │ ├── title_spec.rb │ └── version_spec.rb ├── openapi_spec.rb └── parsers │ ├── ext │ ├── active_record_spec.rb │ └── alba_spec.rb │ ├── request_spec.rb │ ├── response_spec.rb │ ├── shared_reference_spec.rb │ └── yaml_spec.rb ├── params_parser_spec.rb ├── rage ├── cli_spec.rb ├── hooks_spec.rb ├── request_spec.rb └── response_spec.rb ├── router ├── constraints_spec.rb ├── controller_spec.rb ├── defaults_spec.rb ├── dsl_spec.rb ├── mount_spec.rb ├── parametric_routes_spec.rb ├── static_routes_spec.rb ├── util_spec.rb └── wildcard_routes_spec.rb ├── rspec ├── config.ru ├── config │ └── application.rb └── rspec_spec.rb ├── setup_spec.rb ├── spec_helper.rb └── support ├── contexts ├── mocked_classes.rb └── mocked_rage_routes.rb ├── controller_helper.rb ├── custom_matchers.rb ├── integration_helper.rb ├── reactor_helper.rb ├── request_helper.rb └── websocket_helper.rb /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Set up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ruby 18 | bundler-cache: true 19 | - name: Generate documentation 20 | run: bundle exec yardoc 21 | - name: Deploy 22 | uses: cloudflare/wrangler-action@v3 23 | with: 24 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 25 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 26 | command: pages deploy doc --project-name rage-rb --branch main 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | max-parallel: 1 16 | matrix: 17 | ruby: 18 | - '3.2' 19 | - '3.3' 20 | - '3.4' 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run the default task 29 | env: 30 | TEST_HTTP_URL: ${{ secrets.TEST_HTTP_URL }} 31 | TEST_PG_URL: ${{ secrets.TEST_PG_URL }} 32 | TEST_MYSQL_URL: ${{ secrets.TEST_MYSQL_URL }} 33 | TEST_REDIS_URL: ${{ secrets.TEST_REDIS_URL }} 34 | ENABLE_EXTERNAL_TESTS: ${{ secrets.ENABLE_EXTERNAL_TESTS }} 35 | run: bundle exec rake 36 | linter: 37 | runs-on: ubuntu-latest 38 | name: Code Style 39 | steps: 40 | - uses: actions/checkout@v3 41 | - name: Set up Ruby 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ruby 45 | bundler-cache: true 46 | - name: Run Linter 47 | run: bundle exec rubocop 48 | docs: 49 | runs-on: ubuntu-latest 50 | name: Docs 51 | steps: 52 | - uses: actions/checkout@v3 53 | - name: Set up Ruby 54 | uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: ruby 57 | bundler-cache: true 58 | - name: Run YARD 59 | run: bundle exec yardoc --fail-on-warning 60 | cli: 61 | runs-on: ubuntu-latest 62 | name: CLI 63 | steps: 64 | - uses: actions/checkout@v3 65 | - name: Set up Ruby 66 | uses: ruby/setup-ruby@v1 67 | with: 68 | ruby-version: ruby 69 | bundler-cache: true 70 | - name: Build the gem 71 | run: gem build -o rage-local.gem && gem install rage-local.gem --no-document 72 | - name: Create a project 73 | run: rage new my_app 74 | - name: Start the server 75 | working-directory: ./my_app 76 | run: bundle install && bundle exec rage s& 77 | - name: Test the default route 78 | run: curl --fail http://localhost:3000 79 | - name: Run the routes task 80 | working-directory: ./my_app 81 | run: bundle exec rage routes 82 | - name: Add a Rake task 83 | working-directory: ./my_app 84 | run: printf "desc \"Prints From Rake\"\nnamespace :hello do\ntask :print do\nend\nend" > lib/tasks/hello.rake 85 | - name: Check available Rake tasks 86 | working-directory: ./my_app 87 | run: bundle exec rake --tasks | grep "rake hello:print" 88 | - name: Run the Rake task 89 | working-directory: ./my_app 90 | run: bundle exec rake hello:print 91 | cli-d: 92 | runs-on: ubuntu-latest 93 | name: CLI DB 94 | steps: 95 | - uses: actions/checkout@v3 96 | - name: Set up Ruby 97 | uses: ruby/setup-ruby@v1 98 | with: 99 | ruby-version: ruby 100 | bundler-cache: true 101 | - name: Build the gem 102 | run: gem build -o rage-local.gem && gem install rage-local.gem --no-document 103 | - name: Create a project 104 | run: rage new my_app -d sqlite3 105 | - name: Bundle install 106 | working-directory: ./my_app 107 | run: bundle 108 | - name: List available tasks 109 | working-directory: ./my_app 110 | shell: 'script -q -e -c "bash {0}"' 111 | run: bundle exec rage --tasks 112 | - name: Create a database 113 | working-directory: ./my_app 114 | run: bundle exec rage db:create 115 | - name: Create a model 116 | working-directory: ./my_app 117 | run: bundle exec rage g model User 118 | - name: Migrate 119 | working-directory: ./my_app 120 | run: bundle exec rage db:migrate 121 | - name: Start the server 122 | working-directory: ./my_app 123 | run: bundle install && bundle exec rage s& 124 | - name: Test the default route 125 | run: curl --fail http://localhost:3000 126 | 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | .DS_Store 10 | Gemfile.lock 11 | *.gem 12 | .idea 13 | 14 | # rspec failure tracking 15 | .rspec_status 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --exclude lib/rage/templates --exclude lib/rage/rspec --exclude lib/rage/rails --markup markdown --no-private -o doc 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in rage.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "rspec", "~> 3.0" 11 | gem "yard" 12 | gem "rubocop", "~> 1.65.0", require: false 13 | 14 | group :test do 15 | gem "activesupport" 16 | gem "http" 17 | gem "pg" 18 | gem "mysql2" 19 | gem "bigdecimal" 20 | gem "connection_pool", "~> 2.0" 21 | gem "rbnacl" 22 | gem "domain_name" 23 | gem "websocket-client-simple" 24 | gem "prism" 25 | gem "redis-client" 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Roman Samoilov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /OVERVIEW.md: -------------------------------------------------------------------------------- 1 | ### Table of Contents 2 | 3 | [API Workflow](#api-workflow)
4 | [Executing Controller Actions](#executing-controller-actions)
5 | [Cable Workflow](#cable-workflow)
6 | [OpenAPI Workflow](#openapi-workflow)
7 | [Design Principles](#design-principles)
8 | 9 | ### API Workflow 10 | 11 | The following diagram describes some of Rage's internal components and the way they interact with each other: 12 | 13 | ![overview](https://github.com/rage-rb/rage/assets/2270393/0d45bbe3-622c-4b17-b8d8-552c567fecb3) 14 | 15 | ### Executing Controller Actions 16 | 17 | When `Rage::Router::DSL` parses the `config/routes.rb` file and calls the `Rage::Router::Backend` class, it registers actions and stores handler procs. 18 | 19 | Consider we have the following controller: 20 | 21 | ```ruby 22 | class UsersController < RageController::API 23 | before_action :find_user 24 | rescue_from ActiveRecord::RecordNotFound, with: :render_not_found 25 | 26 | def show 27 | render json: @user 28 | end 29 | 30 | private 31 | 32 | def find_user 33 | @user = User.find(params[:id]) 34 | end 35 | 36 | def render_not_found(_) 37 | render status: :not_found 38 | end 39 | end 40 | ``` 41 | 42 | Before processing requests to `UsersController#show`, Rage has to [register](https://github.com/rage-rb/rage/blob/master/lib/rage/controller/api.rb#L11) the show action. Registering means defining a new method that will look like this: 43 | 44 | ```ruby 45 | class UsersController 46 | def __run_show 47 | find_user 48 | show 49 | rescue ActiveRecord::RecordNotFound => e 50 | render_not_found(e) 51 | end 52 | end 53 | ``` 54 | 55 | After that, Rage will create and store a handler proc that will look exactly like this: 56 | 57 | ```ruby 58 | ->(env, params) { UsersController.new(env, params).__run_show } 59 | ``` 60 | 61 | All of this happens at boot time. Once the request comes in at runtime, Rage will only need to retrieve the handler proc defined earlier and call it. 62 | 63 | ### Cable Workflow 64 | 65 | The following diagram describes the components of a `Rage::Cable` application: 66 | 67 | ![cable](https://github.com/user-attachments/assets/86db2091-f93a-44f8-9512-c4701770d09e) 68 | 69 | ### OpenAPI Workflow 70 | 71 | The following diagram describes the flow of `Rage::OpenAPI`: 72 | 73 | 74 | 75 | ### Design Principles 76 | 77 | * **Lean Happy Path:** we try to execute as many operations as possible during server initialization to minimize workload during request processing. Additionally, new features should be designed to avoid impacting the framework performance for users who do not utilize those features. 78 | 79 | * **Performance Over Code Style:** we recognize the distinct requirements of framework and client code. Testability, readability, and maintainability are crucial for client code used in application development. Conversely, library code addresses different tasks and should be designed with different objectives. In library code, performance and abstraction to enable future modifications while maintaining backward compatibility take precedence over typical client code concerns, though testability and readability remain important. 80 | 81 | * **Rails Compatibility:** Rails compatibility is a key objective to ensure a seamless transition for developers. While it may not be feasible to replicate every method implemented in Rails, the framework should function in a familiar and expected manner. 82 | 83 | * **Single-Threaded Fiber-Based Approach:** each request is processed in a separate, isolated execution context (Fiber), pausing whenever it encounters blocking I/O. This single-threaded approach eliminates thread synchronization overhead, leading to enhanced performance and simplified code. 84 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "rage" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | BUNDLE_WITHOUT=test bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /exe/rage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../lib/rage/cli" 4 | Rage::CLI.start 5 | -------------------------------------------------------------------------------- /lib/rage-rb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | require "json" 5 | require "iodine" 6 | require "pathname" 7 | 8 | module Rage 9 | def self.application 10 | with_middlewares(Application.new(__router), config.middleware.middlewares) 11 | end 12 | 13 | def self.multi_application 14 | Rage::Router::Util::Cascade.new(application, Rails.application) 15 | end 16 | 17 | def self.cable 18 | Rage::Cable 19 | end 20 | 21 | def self.openapi 22 | Rage::OpenAPI 23 | end 24 | 25 | def self.routes 26 | Rage::Router::DSL.new(__router) 27 | end 28 | 29 | def self.__router 30 | @__router ||= Rage::Router::Backend.new 31 | end 32 | 33 | def self.config 34 | @config ||= Rage::Configuration.new 35 | end 36 | 37 | def self.configure(&) 38 | config.instance_eval(&) 39 | config.__finalize 40 | end 41 | 42 | def self.env 43 | @__env ||= Rage::Env.new(ENV["RAGE_ENV"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development") 44 | end 45 | 46 | def self.groups 47 | [:default, Rage.env.to_sym] 48 | end 49 | 50 | def self.root 51 | @root ||= Pathname.new(".").expand_path 52 | end 53 | 54 | def self.logger 55 | @logger ||= config.logger 56 | end 57 | 58 | def self.load_middlewares(_) 59 | puts "`Rage.load_middlewares` is deprecated and has been merged into `Rage.application`. Please remove this call." 60 | end 61 | 62 | def self.code_loader 63 | @code_loader ||= Rage::CodeLoader.new 64 | end 65 | 66 | def self.patch_active_record_connection_pool 67 | patch = proc do 68 | is_connected = ActiveRecord::Base.connection_pool rescue false 69 | if is_connected 70 | Iodine.on_state(:pre_start) { puts "INFO: Patching ActiveRecord::ConnectionPool" } 71 | Iodine.on_state(:on_start) do 72 | ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool| 73 | pool.extend(Rage::Ext::ActiveRecord::ConnectionPool) 74 | pool.__init_rage_extension 75 | end 76 | end 77 | else 78 | puts "WARNING: DB connection is not established - can't patch ActiveRecord::ConnectionPool" 79 | end 80 | end 81 | 82 | if Rage.config.internal.rails_mode 83 | Rails.configuration.after_initialize(&patch) 84 | else 85 | patch.call 86 | end 87 | end 88 | 89 | def self.load_tasks 90 | Rage::Tasks.init 91 | end 92 | 93 | # @private 94 | def self.with_middlewares(app, middlewares) 95 | middlewares.reverse.inject(app) do |next_in_chain, (middleware, args, block)| 96 | # in Rails compatibility mode we first check if the middleware is a part of the Rails middleware stack; 97 | # if it is - it is expected to be built using `ActionDispatch::MiddlewareStack::Middleware#build` 98 | if Rage.config.internal.rails_mode 99 | rails_middleware = Rails.application.config.middleware.middlewares.find { |m| m.name == middleware.name } 100 | end 101 | 102 | if rails_middleware 103 | rails_middleware.build(next_in_chain) 104 | else 105 | middleware.new(next_in_chain, *args, &block) 106 | end 107 | end 108 | end 109 | 110 | class << self 111 | alias_method :configuration, :config 112 | end 113 | 114 | module Router 115 | module Strategies 116 | end 117 | 118 | module DSLPlugins 119 | end 120 | end 121 | 122 | module Ext 123 | module ActiveRecord 124 | autoload :ConnectionPool, "rage/ext/active_record/connection_pool" 125 | end 126 | end 127 | 128 | autoload :Tasks, "rage/tasks" 129 | autoload :Cookies, "rage/cookies" 130 | autoload :Session, "rage/session" 131 | autoload :Cable, "rage/cable/cable" 132 | autoload :OpenAPI, "rage/openapi/openapi" 133 | end 134 | 135 | module RageController 136 | end 137 | 138 | require_relative "rage/env" 139 | -------------------------------------------------------------------------------- /lib/rage.rb: -------------------------------------------------------------------------------- 1 | require "rage-rb" 2 | -------------------------------------------------------------------------------- /lib/rage/all.rb: -------------------------------------------------------------------------------- 1 | require_relative "../rage-rb" 2 | 3 | require_relative "version" 4 | require_relative "hooks" 5 | require_relative "application" 6 | require_relative "fiber" 7 | require_relative "fiber_scheduler" 8 | require_relative "configuration" 9 | require_relative "request" 10 | require_relative "response" 11 | require_relative "uploaded_file" 12 | require_relative "errors" 13 | require_relative "params_parser" 14 | require_relative "code_loader" 15 | 16 | require_relative "router/strategies/host" 17 | require_relative "router/backend" 18 | require_relative "router/constrainer" 19 | require_relative "router/dsl" 20 | require_relative "router/handler_storage" 21 | require_relative "router/node" 22 | require_relative "router/util" 23 | 24 | require_relative "controller/api" 25 | 26 | require_relative "logger/text_formatter" 27 | require_relative "logger/json_formatter" 28 | require_relative "logger/logger" 29 | 30 | require_relative "middleware/origin_validator" 31 | require_relative "middleware/fiber_wrapper" 32 | require_relative "middleware/cors" 33 | require_relative "middleware/reloader" 34 | require_relative "middleware/request_id" 35 | 36 | if defined?(Sidekiq) 37 | require_relative "sidekiq_session" 38 | end 39 | -------------------------------------------------------------------------------- /lib/rage/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Application 4 | def initialize(router) 5 | @router = router 6 | @exception_app = build_exception_app 7 | end 8 | 9 | def call(env) 10 | init_logger(env) 11 | 12 | handler = @router.lookup(env) 13 | 14 | response = if handler 15 | params = Rage::ParamsParser.prepare(env, handler[:params]) 16 | handler[:handler].call(env, params) 17 | else 18 | [404, {}, ["Not Found"]] 19 | end 20 | 21 | rescue Rage::Errors::BadRequest => e 22 | response = @exception_app.call(400, e) 23 | 24 | rescue Exception => e 25 | response = @exception_app.call(500, e) 26 | 27 | ensure 28 | finalize_logger(env, response, params) 29 | end 30 | 31 | private 32 | 33 | DEFAULT_LOG_CONTEXT = {}.freeze 34 | private_constant :DEFAULT_LOG_CONTEXT 35 | 36 | def init_logger(env) 37 | Thread.current[:rage_logger] = { 38 | tags: [(env["rage.request_id"] ||= Iodine::Rack::Utils.gen_request_tag)], 39 | context: DEFAULT_LOG_CONTEXT, 40 | request_start: Process.clock_gettime(Process::CLOCK_MONOTONIC) 41 | } 42 | end 43 | 44 | def finalize_logger(env, response, params) 45 | logger = Thread.current[:rage_logger] 46 | 47 | duration = ( 48 | (Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger[:request_start]) * 1000 49 | ).round(2) 50 | 51 | logger[:final] = { env:, params:, response:, duration: } 52 | Rage.logger.info("") 53 | logger[:final] = nil 54 | end 55 | 56 | def build_exception_app 57 | if Rage.env.development? 58 | ->(status, e) do 59 | exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" 60 | Rage.logger.error(exception_str) 61 | [status, {}, [exception_str]] 62 | end 63 | else 64 | ->(status, e) do 65 | exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" 66 | Rage.logger.error(exception_str) 67 | [status, {}, []] 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/rage/cable/adapters/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Cable::Adapters::Base 4 | def pick_a_worker(&block) 5 | _lock, lock_path = Tempfile.new.yield_self { |file| [file, file.path] } 6 | 7 | Iodine.on_state(:on_start) do 8 | if File.new(lock_path).flock(File::LOCK_EX | File::LOCK_NB) 9 | if Rage.logger.debug? 10 | puts "INFO: #{Process.pid} is managing #{self.class.name.split("::").last} subscriptions." 11 | end 12 | block.call 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rage/cable/adapters/redis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | if !defined?(RedisClient) 6 | fail <<~ERR 7 | 8 | Redis adapter depends on the `redis-client` gem. Add the following line to your Gemfile: 9 | gem "redis-client" 10 | 11 | ERR 12 | end 13 | 14 | class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base 15 | REDIS_STREAM_NAME = "rage:cable:messages" 16 | DEFAULT_REDIS_OPTIONS = { reconnect_attempts: [0.05, 0.1, 0.5] } 17 | REDIS_MIN_VERSION_SUPPORTED = Gem::Version.create(6) 18 | 19 | def initialize(config) 20 | @redis_stream = if (prefix = config.delete(:channel_prefix)) 21 | "#{prefix}:#{REDIS_STREAM_NAME}" 22 | else 23 | REDIS_STREAM_NAME 24 | end 25 | 26 | @redis_config = RedisClient.config(**DEFAULT_REDIS_OPTIONS.merge(config)) 27 | @server_uuid = SecureRandom.uuid 28 | 29 | redis_version = get_redis_version 30 | if redis_version < REDIS_MIN_VERSION_SUPPORTED 31 | raise "Redis adapter only supports Redis 6+. Detected Redis version: #{redis_version}." 32 | end 33 | 34 | @trimming_strategy = redis_version < Gem::Version.create("6.2.0") ? :maxlen : :minid 35 | 36 | pick_a_worker { poll } 37 | end 38 | 39 | def publish(stream_name, data) 40 | message_uuid = SecureRandom.uuid 41 | 42 | publish_redis.call( 43 | "XADD", 44 | @redis_stream, 45 | trimming_method, "~", trimming_value, 46 | "*", 47 | "1", stream_name, 48 | "2", data.to_json, 49 | "3", @server_uuid, 50 | "4", message_uuid 51 | ) 52 | end 53 | 54 | private 55 | 56 | def publish_redis 57 | @publish_redis ||= @redis_config.new_client 58 | end 59 | 60 | def trimming_method 61 | @trimming_strategy == :maxlen ? "MAXLEN" : "MINID" 62 | end 63 | 64 | def trimming_value 65 | @trimming_strategy == :maxlen ? "10000" : ((Time.now.to_f - 5 * 60) * 1000).to_i 66 | end 67 | 68 | def get_redis_version 69 | service_redis = @redis_config.new_client 70 | version = service_redis.call("INFO").match(/redis_version:([[:graph:]]+)/)[1] 71 | 72 | Gem::Version.create(version) 73 | 74 | rescue RedisClient::Error => e 75 | puts "FATAL: Couldn't connect to Redis - all broadcasts will be limited to the current server." 76 | puts e.backtrace.join("\n") 77 | REDIS_MIN_VERSION_SUPPORTED 78 | 79 | ensure 80 | service_redis.close 81 | end 82 | 83 | def error_backoff_intervals 84 | @error_backoff_intervals ||= Enumerator.new do |y| 85 | y << 0.2 << 0.5 << 1 << 2 << 5 86 | loop { y << 10 } 87 | end 88 | end 89 | 90 | def poll 91 | unless Fiber.scheduler 92 | Fiber.set_scheduler(Rage::FiberScheduler.new) 93 | end 94 | 95 | Iodine.on_state(:start_shutdown) do 96 | @stopping = true 97 | end 98 | 99 | Fiber.schedule do 100 | read_redis = @redis_config.new_client 101 | last_id = (Time.now.to_f * 1000).to_i 102 | last_message_uuid = nil 103 | 104 | loop do 105 | data = read_redis.blocking_call(5, "XREAD", "COUNT", "100", "BLOCK", "5000", "STREAMS", @redis_stream, last_id) 106 | 107 | if data 108 | data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _, broadcaster_uuid, _, message_uuid)| 109 | if broadcaster_uuid != @server_uuid && message_uuid != last_message_uuid 110 | Rage.cable.__protocol.broadcast(stream_name, JSON.parse(serialized_data)) 111 | end 112 | 113 | last_id = id 114 | last_message_uuid = message_uuid 115 | end 116 | end 117 | 118 | rescue RedisClient::Error => e 119 | Rage.logger.error("Subscriber error: #{e.message} (#{e.class})") 120 | sleep error_backoff_intervals.next 121 | rescue => e 122 | @stopping ? break : raise(e) 123 | else 124 | error_backoff_intervals.rewind 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/rage/cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Cable::Connection 4 | # @private 5 | attr_reader :__identified_by_map 6 | 7 | # Mark a key as being a connection identifier index that can then be used to find the specific connection again later. 8 | # Common identifiers are `current_user` and `current_account`, but could be anything. 9 | # 10 | # @param identifiers [Symbol,Array] 11 | def self.identified_by(*identifiers) 12 | identifiers.each do |method_name| 13 | define_method(method_name) do 14 | @__identified_by_map[method_name] 15 | end 16 | 17 | define_method("#{method_name}=") do |data| 18 | @__identified_by_map[method_name] = data 19 | end 20 | 21 | Rage::Cable::Channel.__prepare_id_method(method_name) 22 | end 23 | end 24 | 25 | # @private 26 | def initialize(env, identified_by = {}) 27 | @__env = env 28 | @__identified_by_map = identified_by 29 | end 30 | 31 | # @private 32 | def connect 33 | end 34 | 35 | # Reject the WebSocket connection. 36 | def reject_unauthorized_connection 37 | @rejected = true 38 | end 39 | 40 | def rejected? 41 | !!@rejected 42 | end 43 | 44 | # Get the request object. See {Rage::Request}. 45 | # 46 | # @return [Rage::Request] 47 | def request 48 | @__request ||= Rage::Request.new(@__env) 49 | end 50 | 51 | # Get the cookie object. See {Rage::Cookies}. 52 | # 53 | # @return [Rage::Cookies] 54 | def cookies 55 | @__cookies ||= Rage::Cookies.new(@__env, ReadOnlyHash.new) 56 | end 57 | 58 | # Get the session object. See {Rage::Session}. 59 | # 60 | # @return [Rage::Session] 61 | def session 62 | @__session ||= Rage::Session.new(cookies) 63 | end 64 | 65 | # Get URL query parameters. 66 | # 67 | # @return [Hash{Symbol=>String,Array,Hash}] 68 | def params 69 | @__params ||= Iodine::Rack::Utils.parse_nested_query(@__env["QUERY_STRING"]) 70 | end 71 | 72 | # @private 73 | class ReadOnlyHash < Hash 74 | def []=(_, _) 75 | raise "Cookies cannot be set for WebSocket clients" 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/rage/cable/protocols/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | 5 | ## 6 | # A protocol defines the structure, rules and semantics for exchanging data between the client and the server. 7 | # A protocol class should inherit from {Rage::Cable::Protocols::Base} and implement the following methods: 8 | # 9 | # * `on_open` 10 | # * `on_message` 11 | # * `serialize` 12 | # 13 | # The optional methods are: 14 | # 15 | # * `protocol_definition` 16 | # * `on_shutdown` 17 | # * `on_close` 18 | # 19 | class Rage::Cable::Protocols::Base 20 | # @private 21 | HANDSHAKE_HEADERS = {} 22 | 23 | class << self 24 | # @param router [Rage::Cable::Router] 25 | def init(router) 26 | @router = router 27 | 28 | # Hash Set(subscription params)> 29 | @subscription_identifiers = Hash.new { |hash, key| hash[key] = Set.new } 30 | 31 | Iodine.on_state(:pre_start) do 32 | # this is a fallback to synchronize subscription identifiers across different worker processes; 33 | # we expect connections to be distributed among all workers, so this code will almost never be called; 34 | # we also synchronize subscriptions with the master process so that the forks that are spun up instead 35 | # of the crashed ones also had access to the identifiers; 36 | Iodine.subscribe("cable:synchronize") do |_, subscription_msg| 37 | stream_name, params = Rage::ParamsParser.json_parse(subscription_msg) 38 | @subscription_identifiers[stream_name] << params 39 | end 40 | end 41 | 42 | Iodine.on_state(:on_finish) do 43 | Iodine.unsubscribe("cable:synchronize") 44 | end 45 | end 46 | 47 | def protocol_definition 48 | HANDSHAKE_HEADERS 49 | end 50 | 51 | # Subscribe to a stream. 52 | # 53 | # @param connection [Rage::Cable::WebSocketConnection] the connection object 54 | # @param name [String] the stream name 55 | # @param params [Hash] parameters associated with the client 56 | def subscribe(connection, name, params) 57 | connection.subscribe("cable:#{name}:#{stream_id(params)}") 58 | 59 | unless @subscription_identifiers[name].include?(params) 60 | @subscription_identifiers[name] << params 61 | ::Iodine.publish("cable:synchronize", [name, params].to_json) 62 | end 63 | end 64 | 65 | # Broadcast data to all clients connected to a stream. 66 | # 67 | # @param name [String] the stream name 68 | # @param data [Object] the data to send 69 | def broadcast(name, data) 70 | @subscription_identifiers[name].each do |params| 71 | ::Iodine.publish("cable:#{name}:#{stream_id(params)}", serialize(params, data)) 72 | end 73 | end 74 | 75 | # Whether the protocol allows remote procedure calls. 76 | # 77 | # @return [Boolean] 78 | def supports_rpc? 79 | true 80 | end 81 | 82 | private 83 | 84 | def stream_id(params) 85 | Digest::MD5.hexdigest(params.to_s) 86 | end 87 | end # class << self 88 | end 89 | -------------------------------------------------------------------------------- /lib/rage/code_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "zeitwerk" 4 | 5 | class Rage::CodeLoader 6 | def initialize 7 | @reloading = false 8 | @autoload_path = Rage.root.join("app") 9 | end 10 | 11 | def setup 12 | @loader = Zeitwerk::Loader.new 13 | 14 | enable_reloading = Rage.env.development? 15 | enable_eager_loading = !Rage.env.development? && !Rage.env.test? 16 | 17 | @loader.push_dir(@autoload_path.to_s) 18 | # The first level of directories in app directory won't be treated as modules 19 | # e.g. app/controllers/pages_controller.rb will be linked to PagesController class 20 | # instead of Controllers::PagesController 21 | @loader.collapse("#{Rage.root}/app/*") 22 | @loader.enable_reloading if enable_reloading 23 | @loader.setup 24 | @loader.eager_load if enable_eager_loading 25 | end 26 | 27 | # in standalone mode - reload the code and the routes 28 | def reload 29 | return unless @loader 30 | 31 | @reloading = true 32 | @loader.reload 33 | 34 | Rage.__router.reset_routes 35 | load("#{Rage.root}/config/routes.rb") 36 | 37 | unless Rage.autoload?(:Cable) # the `Cable` component is loaded 38 | Rage::Cable.__router.reset 39 | end 40 | 41 | unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded 42 | Rage::OpenAPI.__reset_data_cache 43 | end 44 | end 45 | 46 | # in Rails mode - reset the routes; everything else will be done by Rails 47 | def rails_mode_reload 48 | return if @loader 49 | 50 | @reloading = true 51 | Rage.__router.reset_routes 52 | 53 | unless Rage.autoload?(:Cable) # the `Cable` component is loaded 54 | Rage::Cable.__router.reset 55 | end 56 | 57 | unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded 58 | Rage::OpenAPI.__reset_data_cache 59 | end 60 | end 61 | 62 | def reloading? 63 | @reloading 64 | end 65 | 66 | def check_updated! 67 | current_watched = @autoload_path.glob("**/*.rb") + Rage.root.glob("config/routes.rb") + Rage.root.glob("config/openapi_components.*") 68 | current_update_at = current_watched.max_by { |path| path.exist? ? path.mtime.to_f : 0 }&.mtime.to_f 69 | return false if !@last_watched && !@last_update_at 70 | 71 | current_watched.size != @last_watched.size || current_update_at != @last_update_at 72 | 73 | ensure 74 | @last_watched, @last_update_at = current_watched, current_update_at 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/rage/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Env 4 | STANDARD_ENVS = %w(development test staging production) 5 | 6 | def initialize(env) 7 | @env = env 8 | 9 | STANDARD_ENVS.each do |standard_env| 10 | self.class.define_method("#{standard_env}?") { false } if standard_env != @env 11 | end 12 | self.class.define_method("#{@env}?") { true } 13 | end 14 | 15 | def method_missing(method_name, *, &) 16 | method_name.end_with?("?") ? false : super 17 | end 18 | 19 | def respond_to_missing?(method_name, include_private = false) 20 | method_name.end_with?("?") 21 | end 22 | 23 | def ==(other) 24 | @env == other 25 | end 26 | 27 | def to_sym 28 | @env.to_sym 29 | end 30 | 31 | def to_s 32 | @env 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rage/errors.rb: -------------------------------------------------------------------------------- 1 | module Rage::Errors 2 | class BadRequest < StandardError 3 | end 4 | 5 | class RouterError < StandardError 6 | end 7 | 8 | class UnknownHTTPMethod < StandardError 9 | end 10 | 11 | class InvalidCustomProxy < StandardError 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rage/ext/setup.rb: -------------------------------------------------------------------------------- 1 | if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("6") 2 | fail "Rage is only compatible with Active Record 6+. Detected Active Record version: #{ActiveRecord.version}." 3 | end 4 | 5 | # set ActiveSupport isolation level 6 | if defined?(ActiveSupport::IsolatedExecutionState) 7 | ActiveSupport::IsolatedExecutionState.isolation_level = :fiber 8 | end 9 | 10 | # patch Active Record 6.0 to accept the role argument; 11 | # for Active Record 6.1 and 7.0 with `legacy_connection_handling == false` this also 12 | # allows to correctly handle the `:all` argument by ignoring it 13 | if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.1") 14 | %i(active_connections? connection_pool_list clear_active_connections!).each do |m| 15 | ActiveRecord::Base.connection_handler.define_singleton_method(m) do |role = nil| 16 | role == :all ? super() : super(role) 17 | end 18 | end 19 | end 20 | 21 | # release ActiveRecord connections on yield 22 | if defined?(ActiveRecord) && Rage.config.internal.patch_ar_pool? 23 | if ENV["RAGE_DISABLE_AR_WEAK_CONNECTIONS"] 24 | unless Rage.config.internal.should_manually_release_ar_connections? 25 | puts "WARNING: The RAGE_DISABLE_AR_WEAK_CONNECTIONS setting does not have any effect with Active Record 7.2+" 26 | end 27 | elsif Rage.config.internal.should_manually_release_ar_connections? 28 | class Fiber 29 | def self.defer(fileno) 30 | f = Fiber.current 31 | f.__awaited_fileno = fileno 32 | 33 | res = Fiber.yield 34 | 35 | if ActiveRecord::Base.connection_handler.active_connections?(:all) 36 | Iodine.defer do 37 | if fileno != f.__awaited_fileno || !f.alive? 38 | ActiveRecord::Base.connection_handler.connection_pool_list(:all).each { |pool| pool.release_connection(f) } 39 | end 40 | end 41 | end 42 | 43 | res 44 | end 45 | end 46 | end 47 | end 48 | 49 | # make `ActiveRecord::ConnectionPool` work correctly with fibers 50 | if defined?(ActiveRecord::ConnectionAdapters::ConnectionPool) 51 | ActiveRecord::ConnectionAdapters::ConnectionPool 52 | module ActiveRecord::ConnectionAdapters 53 | class ConnectionPool 54 | def connection_cache_key(_) 55 | Fiber.current 56 | end 57 | end 58 | end 59 | end 60 | 61 | # connect to the database in standalone mode 62 | database_url, database_file = ENV["DATABASE_URL"], Rage.root.join("config/database.yml") 63 | if defined?(ActiveRecord) && !Rage.config.internal.rails_mode && (database_url || database_file.exist?) 64 | # transform database URL to an object 65 | database_url_config = if database_url.nil? 66 | {} 67 | elsif ActiveRecord.version >= Gem::Version.create("6.1.0") 68 | ActiveRecord::Base.configurations 69 | ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.new(database_url).to_hash 70 | else 71 | ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(database_url).to_hash 72 | end 73 | database_url_config.transform_keys!(&:to_s) 74 | 75 | # load config/database.yml 76 | if database_file.exist? 77 | database_file_config = begin 78 | require "yaml" 79 | require "erb" 80 | YAML.safe_load(ERB.new(database_file.read).result, aliases: true) 81 | end 82 | 83 | # merge database URL config into the file config (only if we have one database) 84 | database_file_config.transform_values! do |env_config| 85 | env_config.all? { |_, v| v.is_a?(Hash) } ? env_config : env_config.merge(database_url_config) 86 | end 87 | end 88 | 89 | ActiveRecord::Base.configurations = database_file_config || { Rage.env.to_s => database_url_config } 90 | ActiveRecord::Base.establish_connection(Rage.env.to_sym) 91 | 92 | unless defined?(Rake) 93 | ActiveRecord::Base.logger = Rage.logger if Rage.logger.debug? 94 | ActiveRecord::Base.connection_pool.with_connection {} # validate the connection 95 | end 96 | end 97 | 98 | # patch `ActiveRecord::ConnectionPool` 99 | if defined?(ActiveRecord) && Rage.config.internal.patch_ar_pool? 100 | Rage.patch_active_record_connection_pool 101 | end 102 | -------------------------------------------------------------------------------- /lib/rage/fiber_scheduler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "resolv" 4 | 5 | class Rage::FiberScheduler 6 | MAX_READ = 65536 7 | 8 | def initialize 9 | @root_fiber = Fiber.current 10 | @dns_cache = {} 11 | end 12 | 13 | def io_wait(io, events, timeout = nil) 14 | f = Fiber.current 15 | ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil) { |err| f.resume(err) } 16 | 17 | err = Fiber.defer(io.fileno) 18 | if err == false || (err && err < 0) 19 | err 20 | else 21 | events 22 | end 23 | end 24 | 25 | def io_read(io, buffer, length, offset = 0) 26 | length_to_read = if length == 0 27 | buffer.size > MAX_READ ? MAX_READ : buffer.size 28 | else 29 | length 30 | end 31 | 32 | while true 33 | string = ::Iodine::Scheduler.read(io.fileno, length_to_read, offset) 34 | 35 | if string.nil? 36 | return offset 37 | end 38 | 39 | if string.empty? 40 | return -Errno::EAGAIN::Errno 41 | end 42 | 43 | buffer.set_string(string, offset) 44 | 45 | size = string.bytesize 46 | offset += size 47 | return offset if size < length_to_read || size >= buffer.size 48 | 49 | Fiber.pause 50 | end 51 | end 52 | 53 | unless ENV["RAGE_DISABLE_IO_WRITE"] 54 | def io_write(io, buffer, length, offset = 0) 55 | bytes_to_write = length 56 | bytes_to_write = buffer.size if length == 0 57 | 58 | ::Iodine::Scheduler.write(io.fileno, buffer.get_string, bytes_to_write, offset) 59 | 60 | bytes_to_write - offset 61 | end 62 | end 63 | 64 | def kernel_sleep(duration = nil) 65 | block(nil, duration || 0) 66 | Fiber.pause if duration.nil? || duration < 1 67 | end 68 | 69 | # TODO: GC works a little strange with this closure; 70 | # 71 | # def timeout_after(duration, exception_class = Timeout::Error, *exception_arguments, &block) 72 | # fiber, block_status = Fiber.current, :running 73 | # ::Iodine.run_after((duration * 1000).to_i) do 74 | # fiber.raise(exception_class, exception_arguments) if block_status == :running 75 | # end 76 | 77 | # result = block.call 78 | # block_status = :finished 79 | 80 | # result 81 | # end 82 | 83 | def address_resolve(hostname) 84 | @dns_cache[hostname] ||= begin 85 | ::Iodine.run_after(60_000) do 86 | @dns_cache[hostname] = nil 87 | end 88 | 89 | Resolv.getaddresses(hostname) 90 | end 91 | end 92 | 93 | def block(_blocker, timeout = nil) 94 | f, fulfilled, channel = Fiber.current, false, Fiber.current.__block_channel(true) 95 | 96 | resume_fiber_block = proc do 97 | unless fulfilled 98 | fulfilled = true 99 | ::Iodine.defer { ::Iodine.unsubscribe(channel) } 100 | f.resume 101 | end 102 | end 103 | 104 | ::Iodine.subscribe(channel, &resume_fiber_block) 105 | if timeout 106 | ::Iodine.run_after((timeout * 1000).to_i, &resume_fiber_block) 107 | end 108 | 109 | Fiber.yield 110 | end 111 | 112 | def unblock(_blocker, fiber) 113 | ::Iodine.publish(fiber.__block_channel, "", Iodine::PubSub::PROCESS) 114 | end 115 | 116 | def fiber(&block) 117 | parent = Fiber.current 118 | 119 | fiber = if parent == @root_fiber 120 | # the fiber to wrap a request in 121 | Fiber.new(blocking: false) do 122 | Fiber.current.__set_id 123 | Fiber.current.__set_result(block.call) 124 | end 125 | else 126 | # the fiber was created in the user code 127 | logger = Thread.current[:rage_logger] 128 | 129 | Fiber.new(blocking: false) do 130 | Thread.current[:rage_logger] = logger 131 | Fiber.current.__set_result(block.call) 132 | # send a message for `Fiber.await` to work 133 | Iodine.publish("await:#{parent.object_id}", "", Iodine::PubSub::PROCESS) if parent.alive? 134 | rescue Exception => e 135 | Fiber.current.__set_err(e) 136 | Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive? 137 | end 138 | end 139 | 140 | fiber.resume 141 | 142 | fiber 143 | end 144 | 145 | def close 146 | ::Iodine::Scheduler.close 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/rage/hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hooks 4 | def hooks 5 | @hooks ||= Hash.new { |h, k| h[k] = [] } 6 | end 7 | 8 | def push_hook(callback, hook_family) 9 | hooks[hook_family] << callback if callback.is_a?(Proc) 10 | end 11 | 12 | def run_hooks_for!(hook_family, context = nil) 13 | hooks[hook_family].each do |callback| 14 | if context 15 | context.instance_exec(&callback) 16 | else 17 | callback.call 18 | end 19 | end 20 | 21 | @hooks[hook_family] = [] 22 | 23 | true 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rage/logger/json_formatter.rb: -------------------------------------------------------------------------------- 1 | class Rage::JSONFormatter 2 | def initialize 3 | @pid = Process.pid.to_s 4 | Iodine.on_state(:on_start) do 5 | @pid = Process.pid.to_s 6 | end 7 | end 8 | 9 | def call(severity, timestamp, _, message) 10 | logger = Thread.current[:rage_logger] || { tags: [], context: {} } 11 | tags, context = logger[:tags], logger[:context] 12 | 13 | if !context.empty? 14 | context_msg = "" 15 | context.each { |k, v| context_msg << "\"#{k}\":#{v.to_json}," } 16 | end 17 | 18 | if (final = logger[:final]) 19 | params, env = final[:params], final[:env] 20 | if params && params[:controller] 21 | return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",\"controller\":\"#{Rage::Router::Util.path_to_name(params[:controller])}\",\"action\":\"#{params[:action]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n" 22 | else 23 | # no controller/action keys are written if there are no params 24 | return "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"info\",\"method\":\"#{env["REQUEST_METHOD"]}\",\"path\":\"#{env["PATH_INFO"]}\",#{context_msg}\"status\":#{final[:response][0]},\"duration\":#{final[:duration]}}\n" 25 | end 26 | end 27 | 28 | if tags.length == 1 29 | tags_msg = "{\"tags\":[\"#{tags[0]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\"" 30 | elsif tags.length == 2 31 | tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\"],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\"" 32 | elsif tags.length == 0 33 | tags_msg = "{\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\"" 34 | else 35 | tags_msg = "{\"tags\":[\"#{tags[0]}\",\"#{tags[1]}\"" 36 | i = 2 37 | while i < tags.length 38 | tags_msg << ",\"#{tags[i]}\"" 39 | i += 1 40 | end 41 | tags_msg << "],\"timestamp\":\"#{timestamp}\",\"pid\":\"#{@pid}\",\"level\":\"#{severity}\"" 42 | end 43 | 44 | "#{tags_msg},#{context_msg}\"message\":\"#{message}\"}\n" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rage/logger/text_formatter.rb: -------------------------------------------------------------------------------- 1 | class Rage::TextFormatter 2 | def initialize 3 | @pid = Process.pid 4 | Iodine.on_state(:on_start) do 5 | @pid = Process.pid 6 | end 7 | end 8 | 9 | def call(severity, timestamp, _, message) 10 | logger = Thread.current[:rage_logger] || { tags: [], context: {} } 11 | tags, context = logger[:tags], logger[:context] 12 | 13 | if !context.empty? 14 | context_msg = "" 15 | context.each { |k, v| context_msg << "#{k}=#{v} " } 16 | end 17 | 18 | if (final = logger[:final]) 19 | params, env = final[:params], final[:env] 20 | if params && params[:controller] 21 | return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} controller=#{Rage::Router::Util.path_to_name(params[:controller])} action=#{params[:action]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n" 22 | else 23 | # no controller/action keys are written if there are no params 24 | return "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=info method=#{env["REQUEST_METHOD"]} path=#{env["PATH_INFO"]} #{context_msg}status=#{final[:response][0]} duration=#{final[:duration]}\n" 25 | end 26 | end 27 | 28 | if tags.length == 1 29 | tags_msg = "[#{tags[0]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}" 30 | elsif tags.length == 2 31 | tags_msg = "[#{tags[0]}][#{tags[1]}] timestamp=#{timestamp} pid=#{@pid} level=#{severity}" 32 | elsif tags.length == 0 33 | tags_msg = "timestamp=#{timestamp} pid=#{@pid} level=#{severity}" 34 | else 35 | tags_msg = "[#{tags[0]}][#{tags[1]}]" 36 | i = 2 37 | while i < tags.length 38 | tags_msg << "[#{tags[i]}]" 39 | i += 1 40 | end 41 | tags_msg << " timestamp=#{timestamp} pid=#{@pid} level=#{severity}" 42 | end 43 | 44 | "#{tags_msg} #{context_msg}message=#{message}\n" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rage/middleware/fiber_wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # The middleware wraps every request in a separate Fiber. It should always be on the top of the middleware stack, 5 | # as it implements a custom defer protocol, which may break middlewares located above. 6 | # 7 | class Rage::FiberWrapper 8 | def initialize(app) 9 | Iodine.on_state(:on_start) do 10 | unless Fiber.scheduler 11 | Fiber.set_scheduler(Rage::FiberScheduler.new) 12 | end 13 | end 14 | @app = app 15 | end 16 | 17 | def call(env) 18 | fiber = Fiber.schedule do 19 | @app.call(env) 20 | ensure 21 | # notify Iodine the request can now be resumed 22 | Iodine.publish(Fiber.current.__get_id, "", Iodine::PubSub::PROCESS) 23 | end 24 | 25 | # the fiber encountered blocking IO and yielded; instruct Iodine to pause the request 26 | if fiber.alive? 27 | [:__http_defer__, fiber] 28 | else 29 | fiber.__get_result 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rage/middleware/origin_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OriginValidator 4 | def initialize(app, *allowed_origins) 5 | @app = app 6 | @validator = build_validator(allowed_origins) 7 | end 8 | 9 | def call(env) 10 | if @validator.call(env) 11 | @app.call(env) 12 | else 13 | Rage.logger.error("Request origin not allowed: #{env["HTTP_ORIGIN"]}") 14 | [404, {}, ["Not Found"]] 15 | end 16 | end 17 | 18 | private 19 | 20 | def build_validator(allowed_origins) 21 | if allowed_origins.empty? 22 | ->(env) { false } 23 | else 24 | origins_eval = allowed_origins.map { |origin| 25 | origin.is_a?(Regexp) ? 26 | "origin =~ /#{origin.source}/.freeze" : 27 | "origin == '#{origin}'.freeze" 28 | }.join(" || ") 29 | 30 | eval <<-RUBY 31 | ->(env) do 32 | origin = env["HTTP_ORIGIN".freeze] 33 | #{origins_eval} 34 | end 35 | RUBY 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rage/middleware/reloader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Reloader 4 | def initialize(app) 5 | Iodine.on_state(:on_start) do 6 | Rage.code_loader.check_updated! 7 | end 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | with_reload do 13 | @app.call(env) 14 | end 15 | rescue Exception => e 16 | exception_str = "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" 17 | puts(exception_str) 18 | [500, {}, [exception_str]] 19 | end 20 | 21 | private 22 | 23 | def with_reload 24 | if Rage.code_loader.check_updated! 25 | Fiber.new(blocking: true) { 26 | Rage.code_loader.reload 27 | yield 28 | }.resume 29 | else 30 | yield 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/rage/middleware/request_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # The middleware establishes a connection between the `X-Request-Id` header (typically generated by a firewall, load balancer, or web server) and 5 | # Rage's internal logging system. It ensures that: 6 | # 7 | # 1. All logs produced during the request are tagged with the value submitted in the `X-Request-Id` header. 8 | # 2. The request ID is added back to the response in the `X-Request-Id` header. If no `X-Request-Id` header was provided in the request, the middleware adds an internally generated ID to the response. 9 | # 10 | # Additionally, the `X-Request-Id` header value is sanitized to a maximum of 255 characters, allowing only alphanumeric characters and dashes. 11 | # 12 | # @example 13 | # Rage.configure do 14 | # config.middleware.use Rage::RequestId 15 | # end 16 | # 17 | class Rage::RequestId 18 | BLACKLISTED_CHARACTERS = /[^\w\-@]/ 19 | 20 | def initialize(app) 21 | @app = app 22 | end 23 | 24 | def call(env) 25 | env["rage.request_id"] = validate_external_request_id(env["HTTP_X_REQUEST_ID"]) 26 | response = @app.call(env) 27 | response[1]["X-Request-Id"] = env["rage.request_id"] 28 | 29 | response 30 | end 31 | 32 | private 33 | 34 | def validate_external_request_id(request_id) 35 | if request_id && !request_id.empty? 36 | request_id = request_id[0...255] if request_id.size > 255 37 | request_id = request_id.gsub(BLACKLISTED_CHARACTERS, "") if request_id =~ BLACKLISTED_CHARACTERS 38 | 39 | request_id 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/rage/openapi/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Build OpenAPI specification for the app. Consists of three steps: 5 | # 6 | # * `Rage::OpenAPI::Builder` - build a tree of action nodes; 7 | # * `Rage::OpenAPI::Parser` - parse OpenAPI tags and save the result into the nodes; 8 | # * `Rage::OpenAPI::Converter` - convert the tree into an OpenAPI spec; 9 | # 10 | class Rage::OpenAPI::Builder 11 | class ParsingError < StandardError 12 | end 13 | 14 | # @param namespace [String, Module] 15 | def initialize(namespace: nil) 16 | @namespace = namespace.to_s if namespace 17 | 18 | @collectors_cache = {} 19 | @nodes = Rage::OpenAPI::Nodes::Root.new 20 | @routes = Rage.__router.routes.group_by { |route| route[:meta][:controller_class] } 21 | end 22 | 23 | def run 24 | parser = Rage::OpenAPI::Parser.new 25 | 26 | @routes.each do |controller, routes| 27 | next if skip_controller?(controller) 28 | 29 | parent_nodes = fetch_ancestors(controller).map do |klass| 30 | @nodes.new_parent_node(klass) { |node| parser.parse_dangling_comments(node, parse_class(klass).dangling_comments) } 31 | end 32 | 33 | routes.each do |route| 34 | action = route[:meta][:action] 35 | 36 | method_comments = fetch_ancestors(controller).filter_map { |klass| 37 | parse_class(klass).method_comments(action) 38 | }.first 39 | 40 | method_node = @nodes.new_method_node(controller, action, parent_nodes) 41 | method_node.http_method, method_node.http_path = route[:method], route[:path] 42 | 43 | parser.parse_method_comments(method_node, method_comments) 44 | end 45 | 46 | rescue ParsingError 47 | Rage::OpenAPI.__log_warn "skipping #{controller.name} because of parsing error" 48 | next 49 | end 50 | 51 | Rage::OpenAPI::Converter.new(@nodes).run 52 | end 53 | 54 | private 55 | 56 | def skip_controller?(controller) 57 | should_skip_controller = controller.nil? || !controller.ancestors.include?(RageController::API) 58 | should_skip_controller ||= !controller.name.start_with?(@namespace) if @namespace 59 | 60 | should_skip_controller 61 | end 62 | 63 | def fetch_ancestors(controller) 64 | controller.ancestors.take_while { |klass| klass != RageController::API } 65 | end 66 | 67 | def parse_class(klass) 68 | @collectors_cache[klass] ||= begin 69 | source_path, _ = Object.const_source_location(klass.name) 70 | ast = Prism.parse_file(source_path) 71 | 72 | raise ParsingError if ast.errors.any? 73 | 74 | # save the "comment => file" association 75 | ast.comments.each do |comment| 76 | comment.location.define_singleton_method(:__source_path) { source_path } 77 | end 78 | 79 | collector = Rage::OpenAPI::Collector.new(ast.comments) 80 | ast.value.accept(collector) 81 | 82 | collector 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/rage/openapi/collector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Collect all global comments or comments attached to methods in a class. 5 | # At this point we don't care whether these are Rage OpenAPI comments or not. 6 | # 7 | class Rage::OpenAPI::Collector < Prism::Visitor 8 | # @param comments [Array] 9 | def initialize(comments) 10 | @comments = comments.dup 11 | @method_comments = {} 12 | end 13 | 14 | def dangling_comments 15 | @comments 16 | end 17 | 18 | def method_comments(method_name) 19 | @method_comments[method_name.to_s] 20 | end 21 | 22 | def visit_def_node(node) 23 | method_comments = [] 24 | start_line = node.location.start_line - 1 25 | 26 | loop do 27 | comment_i = @comments.find_index { |comment| comment.location.start_line == start_line } 28 | if comment_i 29 | comment = @comments.delete_at(comment_i) 30 | method_comments << comment 31 | start_line -= 1 32 | end 33 | 34 | break unless comment 35 | end 36 | 37 | @method_comments[node.name.to_s] = method_comments.reverse 38 | 39 | # reject comments inside methods 40 | @comments.reject! do |comment| 41 | comment.location.start_line >= node.location.start_line && comment.location.start_line <= node.location.end_line 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rage/openapi/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SwaggerUI 8 | 9 | 10 | 11 |
12 | 13 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /lib/rage/openapi/nodes/method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Nodes::Method 4 | attr_reader :controller, :action, :parents 5 | attr_accessor :http_method, :http_path, :summary, :tag, :deprecated, :private, :description, 6 | :request, :responses, :parameters 7 | 8 | # @param controller [RageController::API] 9 | # @param action [String] 10 | # @param parents [Array] 11 | def initialize(controller, action, parents) 12 | @controller = controller 13 | @action = action 14 | @parents = parents 15 | 16 | @responses = {} 17 | @parameters = {} 18 | end 19 | 20 | def root 21 | @parents[0].root 22 | end 23 | 24 | def auth 25 | @parents.flat_map(&:auth) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rage/openapi/nodes/parent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Nodes::Parent 4 | attr_reader :root, :controller 5 | attr_accessor :deprecated, :private, :auth, :responses 6 | 7 | # @param root [Rage::OpenAPI::Nodes::Root] 8 | # @param controller [RageController::API] 9 | def initialize(root, controller) 10 | @root = root 11 | @controller = controller 12 | 13 | @auth = [] 14 | @responses = {} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rage/openapi/nodes/root.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Represents a tree of method nodes. The tree consists of: 5 | # 6 | # * a root node; 7 | # * method nodes, each of which represents an action in a controller; 8 | # * parent nodes attached to one or several method nodes; 9 | # 10 | # A method node together with its parent nodes represent a complete inheritance chain. 11 | # 12 | # Nodes::Root 13 | # | 14 | # Nodes::Parent 15 | # | 16 | # Nodes::Parent 17 | # / \ 18 | # Nodes::Parent Nodes::Parent 19 | # / \ | 20 | # Nodes::Method Nodes::Method Nodes::Method 21 | # 22 | class Rage::OpenAPI::Nodes::Root 23 | attr_reader :leaves 24 | attr_accessor :version, :title 25 | 26 | def initialize 27 | @parent_nodes_cache = {} 28 | @leaves = [] 29 | end 30 | 31 | # @return [Array] 32 | def parent_nodes 33 | @parent_nodes_cache.values 34 | end 35 | 36 | # @param controller [RageController::API] 37 | # @param action [String] 38 | # @param parent_nodes [Array] 39 | # @return [Rage::OpenAPI::Nodes::Method] 40 | def new_method_node(controller, action, parent_nodes) 41 | node = Rage::OpenAPI::Nodes::Method.new(controller, action, parent_nodes) 42 | @leaves << node 43 | 44 | node 45 | end 46 | 47 | # @param controller [RageController::API] 48 | # @return [Rage::OpenAPI::Nodes::Parent] 49 | def new_parent_node(controller) 50 | @parent_nodes_cache[controller] ||= begin 51 | node = Rage::OpenAPI::Nodes::Parent.new(self, controller) 52 | yield(node) 53 | node 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/rage/openapi/parsers/ext/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Parsers::Ext::ActiveRecord 4 | BLACKLISTED_ATTRIBUTES = %w(id created_at updated_at) 5 | 6 | def initialize(namespace: Object, **) 7 | @namespace = namespace 8 | end 9 | 10 | def known_definition?(str) 11 | _, str = Rage::OpenAPI.__try_parse_collection(str) 12 | defined?(ActiveRecord::Base) && @namespace.const_get(str).ancestors.include?(ActiveRecord::Base) 13 | rescue NameError 14 | false 15 | end 16 | 17 | def parse(klass_str) 18 | is_collection, klass_str = Rage::OpenAPI.__try_parse_collection(klass_str) 19 | klass = @namespace.const_get(klass_str) 20 | 21 | schema = {} 22 | 23 | klass.attribute_types.each do |attr_name, attr_type| 24 | next if BLACKLISTED_ATTRIBUTES.include?(attr_name) || 25 | attr_name.end_with?("_id") || 26 | attr_name == klass.inheritance_column || 27 | klass.defined_enums.include?(attr_name) 28 | 29 | schema[attr_name] = case attr_type.type 30 | when :integer 31 | { "type" => "integer" } 32 | when :boolean 33 | { "type" => "boolean" } 34 | when :binary 35 | { "type" => "string", "format" => "binary" } 36 | when :date 37 | { "type" => "string", "format" => "date" } 38 | when :datetime, :time 39 | { "type" => "string", "format" => "date-time" } 40 | when :float 41 | { "type" => "number", "format" => "float" } 42 | when :decimal 43 | { "type" => "number" } 44 | when :json 45 | { "type" => "object" } 46 | else 47 | { "type" => "string" } 48 | end 49 | end 50 | 51 | klass.defined_enums.each do |attr_name, mapping| 52 | schema[attr_name] = { "type" => "string", "enum" => mapping.keys } 53 | end 54 | 55 | result = { "type" => "object" } 56 | result["properties"] = schema if schema.any? 57 | 58 | result = { "type" => "array", "items" => result } if is_collection 59 | 60 | result 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/rage/openapi/parsers/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Parsers::Request 4 | AVAILABLE_PARSERS = [ 5 | Rage::OpenAPI::Parsers::SharedReference, 6 | Rage::OpenAPI::Parsers::YAML, 7 | Rage::OpenAPI::Parsers::Ext::ActiveRecord 8 | ] 9 | 10 | def self.parse(request_tag, namespace:) 11 | parser = AVAILABLE_PARSERS.find do |parser_class| 12 | parser = parser_class.new(namespace:) 13 | break parser if parser.known_definition?(request_tag) 14 | end 15 | 16 | parser.parse(request_tag) if parser 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rage/openapi/parsers/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Parsers::Response 4 | AVAILABLE_PARSERS = [ 5 | Rage::OpenAPI::Parsers::SharedReference, 6 | Rage::OpenAPI::Parsers::Ext::ActiveRecord, 7 | Rage::OpenAPI::Parsers::Ext::Alba, 8 | Rage::OpenAPI::Parsers::YAML 9 | ] 10 | 11 | def self.parse(response_tag, namespace:) 12 | parser = AVAILABLE_PARSERS.find do |parser_class| 13 | parser = parser_class.new(namespace:) 14 | break parser if parser.known_definition?(response_tag) 15 | end 16 | 17 | parser.parse(response_tag) if parser 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rage/openapi/parsers/shared_reference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Parsers::SharedReference 4 | def initialize(**) 5 | end 6 | 7 | def known_definition?(str) 8 | str.start_with?("#/components") 9 | end 10 | 11 | def parse(component_path) 12 | { "$ref" => component_path } if valid_components_ref?(component_path) 13 | end 14 | 15 | private 16 | 17 | def valid_components_ref?(component_path) 18 | shared_components = Rage::OpenAPI.__shared_components 19 | return false if shared_components.empty? 20 | 21 | !!component_path[2..].split("/").reduce(shared_components) do |components, component_key| 22 | components[component_key] if components 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rage/openapi/parsers/yaml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::OpenAPI::Parsers::YAML 4 | def initialize(**) 5 | end 6 | 7 | def known_definition?(yaml) 8 | object = YAML.safe_load(yaml) rescue nil 9 | !!object && object.is_a?(Enumerable) 10 | end 11 | 12 | def parse(yaml) 13 | __parse(YAML.safe_load(yaml)) 14 | end 15 | 16 | private 17 | 18 | def __parse(object) 19 | spec = {} 20 | 21 | if object.is_a?(Hash) 22 | spec = { "type" => "object", "properties" => {} } 23 | 24 | object.each do |key, value| 25 | spec["properties"][key] = if value.is_a?(Enumerable) 26 | __parse(value) 27 | else 28 | type_to_spec(value) 29 | end 30 | end 31 | 32 | elsif object.is_a?(Array) && object.length == 1 33 | spec = { "type" => "array", "items" => object[0].is_a?(Enumerable) ? __parse(object[0]) : type_to_spec(object[0]) } 34 | 35 | elsif object.is_a?(Array) 36 | spec = { "type" => "string", "enum" => object } 37 | end 38 | 39 | spec 40 | end 41 | 42 | private 43 | 44 | def type_to_spec(type) 45 | Rage::OpenAPI.__type_to_spec(type) || { "type" => "string", "enum" => [type] } 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/rage/params_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::ParamsParser 4 | def self.prepare(env, url_params) 5 | has_body, query_string, content_type = env["IODINE_HAS_BODY"], env["QUERY_STRING"], env["CONTENT_TYPE"] 6 | 7 | query_params = Iodine::Rack::Utils.parse_nested_query(query_string) if query_string != "" 8 | unless has_body 9 | if query_params 10 | return query_params.merge!(url_params) 11 | else 12 | return url_params 13 | end 14 | end 15 | 16 | request_params = if content_type.start_with?("application/json") 17 | json_parse(env["rack.input"].read) 18 | elsif content_type.start_with?("application/x-www-form-urlencoded") 19 | Iodine::Rack::Utils.parse_urlencoded_nested_query(env["rack.input"].read) 20 | else 21 | Iodine::Rack::Utils.parse_multipart(env["rack.input"], content_type) 22 | end 23 | 24 | if request_params && !query_params 25 | request_params.merge!(url_params) 26 | elsif request_params && query_params 27 | request_params.merge!(query_params, url_params) 28 | else 29 | url_params 30 | end 31 | 32 | rescue 33 | raise Rage::Errors::BadRequest 34 | end 35 | 36 | if defined?(::FastJsonparser) 37 | def self.json_parse(json) 38 | FastJsonparser.parse(json, symbolize_keys: true) 39 | end 40 | else 41 | def self.json_parse(json) 42 | JSON.parse(json, symbolize_names: true) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/rage/rails.rb: -------------------------------------------------------------------------------- 1 | if Gem::Version.new(Rails.version) < Gem::Version.new(6) 2 | fail "Rage is only compatible with Rails 6+. Detected Rails version: #{Rails.version}." 3 | end 4 | 5 | # load the framework 6 | require "rage/all" 7 | 8 | # patch Rack 9 | Iodine.patch_rack 10 | 11 | # configure the framework 12 | Rage.config.internal.rails_mode = true 13 | 14 | # plug into Rails' Zeitwerk instance to reload the code 15 | Rails.autoloaders.main.on_setup do 16 | if Iodine.running? 17 | Rage.code_loader.rails_mode_reload 18 | end 19 | end 20 | 21 | # patch `ActionDispatch::Reloader` to synchronize `reload!` calls 22 | Rails.configuration.after_initialize do 23 | conditional_mutex = Module.new do 24 | def call(env) 25 | res = if Rails.application.reloader.check! || !$rage_code_loaded 26 | Fiber.new(blocking: true) { super }.resume 27 | else 28 | super 29 | end 30 | $rage_code_loaded = true 31 | 32 | res 33 | end 34 | end 35 | 36 | ActionDispatch::Reloader.prepend(conditional_mutex) 37 | 38 | # use `ActionDispatch::Reloader` in development 39 | if Rage.env.development? 40 | Rage.config.middleware.use ActionDispatch::Reloader 41 | end 42 | end 43 | 44 | # clone Rails logger 45 | Rails.configuration.after_initialize do 46 | if Rails.logger && !Rage.logger 47 | rails_logdev = Rails.logger.yield_self { |logger| 48 | logger.respond_to?(:broadcasts) ? logger.broadcasts.last : logger 49 | }.instance_variable_get(:@logdev) 50 | 51 | Rage.configure do 52 | config.logger = Rage::Logger.new(rails_logdev.dev) if rails_logdev.is_a?(Logger::LogDevice) 53 | end 54 | end 55 | end 56 | 57 | require "rage/ext/setup" 58 | -------------------------------------------------------------------------------- /lib/rage/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | require "time" 5 | 6 | class Rage::Response 7 | ETAG_HEADER = "ETag" 8 | LAST_MODIFIED_HEADER = "Last-Modified" 9 | 10 | # @private 11 | def initialize(headers, body) 12 | @headers = headers 13 | @body = body 14 | end 15 | 16 | # Returns the content of the response as a string. This contains the contents of any calls to `render`. 17 | # @return [String] 18 | def body 19 | @body[0] 20 | end 21 | 22 | # Returns the headers for the response. 23 | # @return [Hash] 24 | def headers 25 | @headers 26 | end 27 | 28 | # Returns ETag response header or +nil+ if it's empty. 29 | # 30 | # @return [String, nil] 31 | def etag 32 | headers[Rage::Response::ETAG_HEADER] 33 | end 34 | 35 | # Sets ETag header to the response. Additionally, it will hashify the value using +Digest::SHA1.hexdigest+. Pass +nil+ for resetting it. 36 | # @note ETag will be always Weak since no strong validation is implemented. 37 | # @note ArgumentError is raised if ETag value is neither +String+, nor +nil+ 38 | # @param etag [String, nil] The etag of the resource in the response. 39 | def etag=(etag) 40 | raise ArgumentError, "Expected `String` but `#{etag.class}` is received" unless etag.is_a?(String) || etag.nil? 41 | 42 | headers[Rage::Response::ETAG_HEADER] = etag.nil? ? nil : %(W/"#{Digest::SHA1.hexdigest(etag)}") 43 | end 44 | 45 | # Returns Last-Modified response header or +nil+ if it's empty. 46 | # 47 | # @return [String, nil] 48 | def last_modified 49 | headers[Rage::Response::LAST_MODIFIED_HEADER] 50 | end 51 | 52 | # Sets Last-Modified header to the response by calling httpdate on the argument. 53 | # @note ArgumentError is raised if +last_modified+ is not a +Time+ object instance 54 | # @param last_modified [Time, nil] The last modified time of the resource in the response. 55 | def last_modified=(last_modified) 56 | raise ArgumentError, "Expected `Time` but `#{last_modified.class}` is received" unless last_modified.is_a?(Time) || last_modified.nil? 57 | 58 | headers[Rage::Response::LAST_MODIFIED_HEADER] = last_modified&.httpdate 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/rage/router/README.md: -------------------------------------------------------------------------------- 1 | This is an almost complete rewrite of https://github.com/delvedor/find-my-way. 2 | 3 | Currrently, the only constraint supported is the `host` constraint. Regexp constraints are likely to be added. Custom/lambda constraints are unlikely to be added. 4 | 5 | Compared to the Rails router, the most notable difference except constraints is that a wildcard segment can only be in the last section of the path and cannot be named. 6 | 7 | ```ruby 8 | Rage.routes.draw do 9 | get "photos/:id", to: "photos#show", constraints: { host: /myhost/ } 10 | 11 | scope path: "api/v1", module: "api/v1" do 12 | get "photos/:id", to: "photos#show" 13 | end 14 | 15 | root to: "photos#index" 16 | 17 | get "*", to: ->(env) { [404, {}, [{ message: "Not Found" }.to_json]] } 18 | end 19 | ``` 20 | -------------------------------------------------------------------------------- /lib/rage/router/constrainer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Router::Constrainer 4 | attr_reader :strategies 5 | 6 | def initialize(custom_strategies) 7 | @strategies = { 8 | host: Rage::Router::Strategies::Host.new 9 | } 10 | 11 | @strategies_in_use = Set.new 12 | end 13 | 14 | def strategy_used?(strategy_name) 15 | @strategies_in_use.include?(strategy_name) 16 | end 17 | 18 | def has_constraint_strategy(strategy_name) 19 | custom_constraint_strategy = @strategies[strategy_name] 20 | if custom_constraint_strategy 21 | return custom_constraint_strategy.custom? || strategy_used?(strategy_name) 22 | end 23 | 24 | false 25 | end 26 | 27 | def derive_constraints(env) 28 | end 29 | 30 | # When new constraints start getting used, we need to rebuild the deriver to derive them. Do so if we see novel constraints used. 31 | def note_usage(constraints) 32 | if constraints 33 | before_size = @strategies_in_use.size 34 | 35 | constraints.each_key do |key| 36 | @strategies_in_use.add(key) 37 | end 38 | 39 | if before_size != @strategies_in_use.size 40 | __build_derive_constraints 41 | end 42 | end 43 | end 44 | 45 | def new_store_for_constraint(constraint) 46 | raise ArgumentError, "No strategy registered for constraint key '#{constraint}'" unless @strategies[constraint] 47 | @strategies[constraint].storage 48 | end 49 | 50 | def validate_constraints(constraints) 51 | constraints.each do |key, value| 52 | strategy = @strategies[key] 53 | raise ArgumentError, "No strategy registered for constraint key '#{key}'" unless strategy 54 | 55 | strategy.validate(value) 56 | end 57 | end 58 | 59 | # Optimization: build a fast function for deriving the constraints for all the strategies at once. We inline the definitions of the version constraint and the host constraint for performance. 60 | # If no constraining strategies are in use (no routes constrain on host, or version, or any custom strategies) then we don't need to derive constraints for each route match, so don't do anything special, and just return undefined 61 | # This allows us to not allocate an object to hold constraint values if no constraints are defined. 62 | def __build_derive_constraints 63 | return if @strategies_in_use.empty? 64 | 65 | lines = ["{"] 66 | 67 | @strategies_in_use.each do |key| 68 | strategy = @strategies[key] 69 | # Optimization: inline the derivation for the common built in constraints 70 | if !strategy.custom? 71 | if key == :host 72 | lines << " host: env['HTTP_HOST'.freeze]," 73 | else 74 | raise ArgumentError, "unknown non-custom strategy for compiling constraint derivation function" 75 | end 76 | else 77 | lines << " #{strategy.name}: @strategies[#{key}].derive_constraint(env)," 78 | end 79 | end 80 | 81 | lines << "}" 82 | 83 | instance_eval <<-RUBY 84 | def derive_constraints(env) 85 | #{lines.join("\n")} 86 | end 87 | RUBY 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/rage/router/dsl_plugins/controller_action_options.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Support the `:controller` and `:action` options. 3 | # 4 | # @example 5 | # get :admins, controller: :users 6 | # @example 7 | # post :search, action: :index 8 | module Rage::Router::DSLPlugins::ControllerActionOptions 9 | %i(get post put patch delete).each do |action_name| 10 | define_method(action_name) do |*args, **kwargs| 11 | if args.length == 1 && !kwargs.has_key?(:to) && (kwargs.has_key?(:controller) || kwargs.has_key?(:action)) 12 | path = args[0] 13 | controller = kwargs.delete(:controller) || @controllers.last || raise(ArgumentError, "Could not derive the controller value from the route definitions") 14 | action = kwargs.delete(:action) || path.split("/").last 15 | end 16 | 17 | if controller && action 18 | kwargs[:to] = "#{controller}##{action}" 19 | super(path, **kwargs) 20 | else 21 | super(*args, **kwargs) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rage/router/dsl_plugins/legacy_hash_notation.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Support legacy URL helpers that use hashes instead of the `:to` keyword argument. 3 | # 4 | # @example 5 | # get "/photos/:id" => "photos#show" 6 | # @example 7 | # mount Sidekiq::Web => "/sidekiq" 8 | # @example 9 | # get "search" => :index 10 | # @example 11 | # get "admin_users" => "users" 12 | module Rage::Router::DSLPlugins::LegacyHashNotation 13 | %i(get post put patch delete).each do |action_name| 14 | define_method(action_name) do |*args, **kwargs| 15 | if args.empty? && !kwargs.empty? 16 | path, handler = kwargs.first 17 | 18 | to = if handler.is_a?(Symbol) 19 | raise ArgumentError, "Could not derive the controller value from the route definitions" if @controllers.empty? 20 | "#{@controllers.last}##{handler}" 21 | elsif handler.is_a?(String) && !handler.include?("#") 22 | "#{handler}##{path.split("/").last}" 23 | elsif handler.is_a?(String) 24 | handler 25 | end 26 | end 27 | 28 | if path && to 29 | options = kwargs.except(path).merge(to: to) 30 | super(path, **options) 31 | else 32 | super(*args, **kwargs) 33 | end 34 | end 35 | end 36 | 37 | def mount(*args, **kwargs) 38 | if args.empty? && !kwargs.empty? 39 | app, at = kwargs.first 40 | options = kwargs.except(app).merge(at: at) 41 | super(app, **options) 42 | else 43 | super 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rage/router/dsl_plugins/legacy_root_notation.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Support legacy root helpers that don't use the `:to` keyword argument. 3 | # 4 | # @example 5 | # root "photos#index" 6 | module Rage::Router::DSLPlugins::LegacyRootNotation 7 | def root(*args, **kwargs) 8 | if args.length == 1 && args[0].is_a?(String) && kwargs.empty? 9 | super(to: args[0]) 10 | else 11 | super 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/rage/router/dsl_plugins/named_route_helpers.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # Support the `as` option. As Rage currently doesn't generate named route helpers, we simply ignore it. 3 | # 4 | # @example 5 | # get "/photos/:id", to: "photos#show", as: :user_photos 6 | module Rage::Router::DSLPlugins::NamedRouteHelpers 7 | %i(get post put patch delete).each do |action_name| 8 | define_method(action_name) do |*args, **kwargs| 9 | kwargs.delete(:as) 10 | super(*args, **kwargs) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rage/router/strategies/host.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Router::Strategies::Host 4 | attr_reader :name, :must_match_when_derived 5 | 6 | def initialize 7 | @name = "host" 8 | @must_match_when_derived = false 9 | end 10 | 11 | def storage 12 | HostStorage.new 13 | end 14 | 15 | def custom? 16 | false 17 | end 18 | 19 | def validate(value) 20 | if !value.is_a?(String) && !value.is_a?(Regexp) 21 | raise ArgumentError, "Host should be a string or a Regexp" 22 | end 23 | end 24 | 25 | class HostStorage 26 | def initialize 27 | @hosts = {} 28 | @regexp_hosts = [] 29 | end 30 | 31 | def get(host) 32 | exact = @hosts[host] 33 | return exact if exact 34 | 35 | @regexp_hosts.each do |regexp| 36 | return regexp[:value] if regexp[:host] =~ host.to_s 37 | end 38 | 39 | nil 40 | end 41 | 42 | def set(host, value) 43 | if host.is_a?(Regexp) 44 | @regexp_hosts << { host: host, value: value } 45 | else 46 | @hosts[host] = value 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/rage/router/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Rage::Router::Util 4 | class << self 5 | # converts controller name in a path form into a class 6 | # `api/v1/users` => `Api::V1::UsersController` 7 | def path_to_class(str) 8 | str = str.capitalize 9 | str.gsub!(/([\/_])([a-zA-Z0-9]+)/) do 10 | if $1 == "/" 11 | "::#{$2.capitalize}" 12 | else 13 | $2.capitalize 14 | end 15 | end 16 | 17 | klass = "#{str}Controller" 18 | if Object.const_defined?(klass) 19 | Object.const_get(klass) 20 | else 21 | raise Rage::Errors::RouterError, "Routing error: could not find the #{klass} class" 22 | end 23 | end 24 | 25 | @@names_map = {} 26 | 27 | # converts controller name in a path form into a string representation of a class 28 | # `api/v1/users` => `"Api::V1::UsersController"` 29 | def path_to_name(str) 30 | @@names_map[str] || begin 31 | @@names_map[str] = path_to_class(str).name 32 | end 33 | end 34 | end 35 | 36 | # @private 37 | class Cascade 38 | def initialize(rage_app, rails_app) 39 | @rage_app = rage_app 40 | @rails_app = rails_app 41 | end 42 | 43 | def call(env) 44 | result = @rage_app.call(env) 45 | return result if result[0] == :__http_defer__ 46 | 47 | if result[1]["X-Cascade"] == "pass" || env["PATH_INFO"].start_with?("/rails/") 48 | @rails_app.call(env) 49 | else 50 | result 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rage/session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | class Rage::Session 6 | # @private 7 | KEY = Rack::RACK_SESSION.to_sym 8 | 9 | # @private 10 | def initialize(cookies) 11 | @cookies = cookies.encrypted 12 | end 13 | 14 | # Writes the value to the session. 15 | # 16 | # @param key [Symbol] 17 | # @param value [String] 18 | def []=(key, value) 19 | write_session(add: { key => value }) 20 | end 21 | 22 | # Returns the value of the key stored in the session or `nil` if the given key is not found. 23 | # 24 | # @param key [Symbol] 25 | def [](key) 26 | read_session[key] 27 | end 28 | 29 | # Returns the value of the given key from the session, or raises `KeyError` if the given key is not found 30 | # and no default value is set. Returns the default value if specified. 31 | # 32 | # @param key [Symbol] 33 | def fetch(key, default = nil, &block) 34 | if default.nil? 35 | read_session.fetch(key, &block) 36 | else 37 | read_session.fetch(key, default, &block) 38 | end 39 | end 40 | 41 | # Deletes the given key from the session. 42 | # 43 | # @param key [Symbol] 44 | def delete(key) 45 | write_session(remove: key) 46 | end 47 | 48 | # Clears the session. 49 | def clear 50 | write_session(clear: true) 51 | end 52 | 53 | # Returns the session as Hash. 54 | def to_hash 55 | read_session 56 | end 57 | 58 | alias_method :to_h, :to_hash 59 | 60 | def empty? 61 | read_session.empty? 62 | end 63 | 64 | # Returns `true` if the given key is present in the session. 65 | def has_key?(key) 66 | read_session.has_key?(key) 67 | end 68 | 69 | alias_method :key?, :has_key? 70 | alias_method :include?, :has_key? 71 | 72 | def each(&block) 73 | read_session.each(&block) 74 | end 75 | 76 | def dig(*keys) 77 | read_session.dig(*keys) 78 | end 79 | 80 | def inspect 81 | "#<#{self.class.name} @session=#{to_h.inspect}" 82 | end 83 | 84 | private 85 | 86 | def write_session(add: nil, remove: nil, clear: nil) 87 | if add 88 | read_session.merge!(add) 89 | elsif remove && read_session.has_key?(remove) 90 | read_session.reject! { |k, _| k == remove } 91 | elsif clear 92 | read_session.clear 93 | end 94 | 95 | @cookies[KEY] = { httponly: true, same_site: :lax, value: read_session.to_json } 96 | end 97 | 98 | def read_session 99 | @session ||= begin 100 | JSON.parse(@cookies[KEY] || "{}", symbolize_names: true) 101 | rescue JSON::ParserError 102 | {} 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/rage/setup.rb: -------------------------------------------------------------------------------- 1 | Iodine.patch_rack 2 | 3 | begin 4 | require_relative "#{Rage.root}/config/environments/#{Rage.env}" 5 | rescue LoadError 6 | raise LoadError, "The <#{Rage.env}> environment could not be found. Please check the environment name." 7 | end 8 | 9 | # Run application initializers 10 | Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initializer) } 11 | 12 | require "rage/ext/setup" 13 | 14 | # Load application classes 15 | Rage.code_loader.setup 16 | 17 | # Run after_initialize hooks 18 | Rage.config.run_after_initialize! 19 | 20 | require_relative "#{Rage.root}/config/routes" 21 | -------------------------------------------------------------------------------- /lib/rage/sidekiq_session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | require "base64" 5 | 6 | ## 7 | # Used **specifically** for compatibility with Sidekiq's Web interface. 8 | # Remove once we have real sessions or once Sidekiq's author decides they 9 | # don't need cookie sessions to protect against CSRF. 10 | # 11 | class Rage::SidekiqSession 12 | KEY = Digest::SHA2.hexdigest(ENV["SECRET_KEY_BASE"] || File.read("Gemfile.lock") + File.read("config/routes.rb")) 13 | SESSION_KEY = "rage.sidekiq.session" 14 | 15 | def self.with_session(env) 16 | env["rack.session"] = session = new(env) 17 | response = yield 18 | 19 | if session.changed 20 | Rack::Utils.set_cookie_header!( 21 | response[1], 22 | SESSION_KEY, 23 | { path: env["SCRIPT_NAME"], httponly: true, same_site: true, value: session.dump } 24 | ) 25 | end 26 | 27 | response 28 | end 29 | 30 | attr_reader :changed 31 | 32 | def initialize(env) 33 | @env = env 34 | session = Rack::Utils.parse_cookies(@env)[SESSION_KEY] 35 | @data = decode_session(session) 36 | end 37 | 38 | def [](key) 39 | @data[key] 40 | end 41 | 42 | def[]=(key, value) 43 | @changed = true 44 | @data[key] = value 45 | end 46 | 47 | def to_hash 48 | @data 49 | end 50 | 51 | def dump 52 | encoded_data = Marshal.dump(@data) 53 | signature = OpenSSL::HMAC.hexdigest("SHA256", KEY, encoded_data) 54 | 55 | Base64.urlsafe_encode64("#{encoded_data}--#{signature}") 56 | end 57 | 58 | private 59 | 60 | def decode_session(session) 61 | return {} unless session 62 | 63 | encoded_data, signature = Base64.urlsafe_decode64(session).split("--") 64 | ref_signature = OpenSSL::HMAC.hexdigest("SHA256", KEY, encoded_data) 65 | 66 | if Rack::Utils.secure_compare(signature, ref_signature) 67 | Marshal.load(encoded_data) 68 | else 69 | {} 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/rage/tasks.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require "standalone_migrations" 3 | rescue LoadError 4 | end 5 | 6 | class Rage::Tasks 7 | class << self 8 | def init 9 | load_db_tasks if defined?(StandaloneMigrations) 10 | load_app_tasks 11 | end 12 | 13 | private 14 | 15 | def load_db_tasks 16 | StandaloneMigrations::Configurator.prepend(Module.new do 17 | def configuration_file 18 | @path ||= begin 19 | @__tempfile = Tempfile.new 20 | @__tempfile.write <<~YAML 21 | config: 22 | database: config/database.yml 23 | YAML 24 | @__tempfile.close 25 | 26 | @__tempfile.path 27 | end 28 | end 29 | end) 30 | 31 | StandaloneMigrations::Tasks.load_tasks 32 | end 33 | 34 | def load_app_tasks 35 | Dir[Rage.root.join("lib/tasks/**/*.rake")].each { |file| load file } 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rage/templates/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rage-rb", "~> <%= Rage::VERSION.match(/\d+.\d+/).to_s %>" 4 | 5 | # Build JSON APIs with ease 6 | # gem "alba" 7 | 8 | # Get 50% to 150% boost when parsing JSON. 9 | # Rage will automatically use FastJsonparser if it is available. 10 | # gem "fast_jsonparser" 11 | -------------------------------------------------------------------------------- /lib/rage/templates/Rakefile: -------------------------------------------------------------------------------- 1 | require_relative "config/application" 2 | Rage.load_tasks 3 | -------------------------------------------------------------------------------- /lib/rage/templates/app-controllers-application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < RageController::API 2 | end 3 | -------------------------------------------------------------------------------- /lib/rage/templates/config-application.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "rage" 3 | Bundler.require(*Rage.groups) 4 | 5 | <% if @use_database -%> 6 | require "active_record" 7 | <% end -%> 8 | require "rage/all" 9 | 10 | Rage.configure do 11 | # use this to add settings that are constant across all environments 12 | end 13 | 14 | require "rage/setup" 15 | -------------------------------------------------------------------------------- /lib/rage/templates/config-environments-development.rb: -------------------------------------------------------------------------------- 1 | Rage.configure do 2 | # Specify the number of server processes to run. Defaults to number of CPU cores. 3 | config.server.workers_count = 1 4 | 5 | # Specify the port the server will listen on. 6 | config.server.port = 3000 7 | 8 | # Specify the logger 9 | config.logger = Rage::Logger.new(STDOUT) 10 | 11 | config.middleware.use Rage::Reloader 12 | end 13 | -------------------------------------------------------------------------------- /lib/rage/templates/config-environments-production.rb: -------------------------------------------------------------------------------- 1 | Rage.configure do 2 | # Specify the number of server processes to run. Defaults to number of CPU cores. 3 | # config.server.workers_count = ENV.fetch("WEB_CONCURRENCY", 1).to_i 4 | 5 | # Specify the port the server will listen on. 6 | config.server.port = 3000 7 | 8 | # Specify the logger 9 | config.logger = Rage::Logger.new(STDOUT) 10 | config.log_level = Logger::INFO 11 | end 12 | -------------------------------------------------------------------------------- /lib/rage/templates/config-environments-test.rb: -------------------------------------------------------------------------------- 1 | Rage.configure do 2 | # Specify the number of server processes to run. Defaults to number of CPU cores. 3 | config.server.workers_count = 1 4 | 5 | # Specify the port the server will listen on. 6 | config.server.port = 3000 7 | 8 | # Specify the logger 9 | config.logger = Rage::Logger.new("log/test.log") 10 | end 11 | -------------------------------------------------------------------------------- /lib/rage/templates/config-initializers-.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/lib/rage/templates/config-initializers-.keep -------------------------------------------------------------------------------- /lib/rage/templates/config-routes.rb: -------------------------------------------------------------------------------- 1 | Rage.routes.draw do 2 | root to: ->(env) { [200, {}, "It works!"] } 3 | end 4 | -------------------------------------------------------------------------------- /lib/rage/templates/config.ru: -------------------------------------------------------------------------------- 1 | require_relative "config/application" 2 | 3 | run Rage.application 4 | -------------------------------------------------------------------------------- /lib/rage/templates/controller-template/controller.rb: -------------------------------------------------------------------------------- 1 | class <%= @controller_name %> < ApplicationController 2 | end 3 | -------------------------------------------------------------------------------- /lib/rage/templates/db-templates/app-models-application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /lib/rage/templates/db-templates/db-seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should ensure the existence of records required to run the application in every environment (production, 2 | # development, test). The code here should be idempotent so that it can be executed at any point in every environment. 3 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). 4 | # 5 | # Example: 6 | # 7 | # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| 8 | # MovieGenre.find_or_create_by!(name: genre_name) 9 | # end 10 | -------------------------------------------------------------------------------- /lib/rage/templates/db-templates/mysql/config-database.yml: -------------------------------------------------------------------------------- 1 | ## 2 | # MySQL. Versions 5.5.8 and up are supported. 3 | # 4 | default: &default 5 | adapter: mysql2 6 | encoding: utf8mb4 7 | pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %> 8 | username: root 9 | password: 10 | socket: /tmp/mysql.sock 11 | 12 | development: 13 | <<: *default 14 | database: <%= @app_name %>_development 15 | 16 | test: 17 | <<: *default 18 | database: <%= @app_name %>_test 19 | 20 | production: 21 | <<: *default 22 | database: <%= @app_name %>_production 23 | username: <%= @app_name %> 24 | password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %> 25 | -------------------------------------------------------------------------------- /lib/rage/templates/db-templates/postgresql/config-database.yml: -------------------------------------------------------------------------------- 1 | ## 2 | # PostgreSQL. Versions 9.3 and up are supported. 3 | # 4 | default: &default 5 | adapter: postgresql 6 | encoding: unicode 7 | pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %> 8 | 9 | development: 10 | <<: *default 11 | database: <%= @app_name %>_development 12 | 13 | test: 14 | <<: *default 15 | database: <%= @app_name %>_test 16 | 17 | production: 18 | <<: *default 19 | database: <%= @app_name %>_production 20 | username: <%= @app_name %> 21 | password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %> 22 | -------------------------------------------------------------------------------- /lib/rage/templates/db-templates/sqlite3/config-database.yml: -------------------------------------------------------------------------------- 1 | ## 2 | # SQLite. Versions 3.8.0 and up are supported. 3 | # 4 | default: &default 5 | adapter: sqlite3 6 | pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %> 7 | timeout: 5000 8 | 9 | development: 10 | <<: *default 11 | database: storage/development.sqlite3 12 | 13 | test: 14 | <<: *default 15 | database: storage/test.sqlite3 16 | 17 | production: 18 | <<: *default 19 | database: storage/production.sqlite3 20 | -------------------------------------------------------------------------------- /lib/rage/templates/db-templates/trilogy/config-database.yml: -------------------------------------------------------------------------------- 1 | ## 2 | # MySQL. Versions 5.5.8 and up are supported. 3 | # 4 | default: &default 5 | adapter: trilogy 6 | encoding: utf8mb4 7 | pool: <%%= ENV.fetch("DB_MAX_THREADS") { 5 } %> 8 | username: root 9 | password: 10 | socket: /tmp/mysql.sock 11 | 12 | development: 13 | <<: *default 14 | database: <%= @app_name %>_development 15 | 16 | test: 17 | <<: *default 18 | database: <%= @app_name %>_test 19 | 20 | production: 21 | <<: *default 22 | database: <%= @app_name %>_production 23 | username: <%= @app_name %> 24 | password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %> 25 | -------------------------------------------------------------------------------- /lib/rage/templates/lib-.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/lib/rage/templates/lib-.keep -------------------------------------------------------------------------------- /lib/rage/templates/lib-tasks-.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/lib/rage/templates/lib-tasks-.keep -------------------------------------------------------------------------------- /lib/rage/templates/log-.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/lib/rage/templates/log-.keep -------------------------------------------------------------------------------- /lib/rage/templates/model-template/model.rb: -------------------------------------------------------------------------------- 1 | class <%= @model_name %> < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /lib/rage/templates/public-.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/lib/rage/templates/public-.keep -------------------------------------------------------------------------------- /lib/rage/uploaded_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # Models uploaded files. 5 | # 6 | # The actual file is accessible via the `file` accessor, though some 7 | # of its interface is available directly for convenience. 8 | # 9 | # Rage will automatically unlink the files, so there is no need to clean them with a separate maintenance task. 10 | class Rage::UploadedFile 11 | # The basename of the file in the client. 12 | attr_reader :original_filename 13 | 14 | # A string with the MIME type of the file. 15 | attr_reader :content_type 16 | 17 | # A `File` object with the actual uploaded file. Note that some of its interface is available directly. 18 | attr_reader :file 19 | alias_method :tempfile, :file 20 | 21 | def initialize(file, original_filename, content_type) 22 | @file = file 23 | @original_filename = original_filename 24 | @content_type = content_type 25 | end 26 | 27 | # Shortcut for `file.read`. 28 | def read(length = nil, buffer = nil) 29 | @file.read(length, buffer) 30 | end 31 | 32 | # Shortcut for `file.open`. 33 | def open 34 | @file.open 35 | end 36 | 37 | # Shortcut for `file.close`. 38 | def close(unlink_now = false) 39 | @file.close(unlink_now) 40 | end 41 | 42 | # Shortcut for `file.path`. 43 | def path 44 | @file.path 45 | end 46 | 47 | # Shortcut for `file.to_path`. 48 | def to_path 49 | @file.to_path 50 | end 51 | 52 | # Shortcut for `file.rewind`. 53 | def rewind 54 | @file.rewind 55 | end 56 | 57 | # Shortcut for `file.size`. 58 | def size 59 | @file.size 60 | end 61 | 62 | # Shortcut for `file.eof?`. 63 | def eof? 64 | @file.eof? 65 | end 66 | 67 | def to_io 68 | @file.to_io 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/rage/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rage 4 | VERSION = "1.16.0" 5 | end 6 | -------------------------------------------------------------------------------- /rage.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/rage/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "rage-rb" 7 | spec.version = Rage::VERSION 8 | spec.authors = ["Roman Samoilov"] 9 | spec.email = ["rsamoi@icloud.com"] 10 | 11 | spec.summary = "Fast web framework compatible with Rails." 12 | spec.homepage = "https://github.com/rage-rb/rage" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 3.2.0" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = "https://github.com/rage-rb/rage" 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(__dir__) do 22 | `git ls-files -z`.split("\x0").reject do |f| 23 | (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor .rubocop]) 24 | end 25 | end 26 | spec.bindir = "exe" 27 | spec.executables = ["rage"] 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_dependency "thor", "~> 1.0" 31 | spec.add_dependency "rack", "~> 2.0" 32 | spec.add_dependency "rage-iodine", "~> 4.1" 33 | spec.add_dependency "zeitwerk", "~> 2.6" 34 | spec.add_dependency "rack-test", "~> 2.1" 35 | spec.add_dependency "rake", ">= 12.0" 36 | end 37 | -------------------------------------------------------------------------------- /spec/cable/channel/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Cable::Channel do 4 | describe "#params" do 5 | subject { described_class.new(nil, :test_params, nil) } 6 | 7 | it "correctly returns params" do 8 | expect(subject.params).to eq(:test_params) 9 | end 10 | end 11 | 12 | describe "#subscription_rejected?" do 13 | subject { described_class.new(nil, nil, nil) } 14 | 15 | it "does not reject by default" do 16 | expect(subject).not_to be_subscription_rejected 17 | end 18 | 19 | it "correctly rejects subscription" do 20 | subject.reject 21 | expect(subject).to be_subscription_rejected 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/cable/channel/data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CableChannelDataSpec 4 | class TestChannel < Rage::Cable::Channel 5 | def receive 6 | verifier.receive 7 | end 8 | end 9 | 10 | class TestChannel2 < Rage::Cable::Channel 11 | def receive(data) 12 | verifier.receive(data) 13 | end 14 | end 15 | end 16 | 17 | RSpec.describe Rage::Cable::Channel do 18 | subject { klass.tap(&:__register_actions).new(nil, nil, nil) } 19 | 20 | let(:verifier) { double } 21 | 22 | before do 23 | allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) 24 | end 25 | 26 | context "expecting no data" do 27 | let(:klass) { CableChannelDataSpec::TestChannel } 28 | 29 | it "correctly processes remote method calls with no data" do 30 | expect(verifier).to receive(:receive).once 31 | subject.__run_action(:receive) 32 | end 33 | 34 | it "correctly processes remote method calls with data" do 35 | expect(verifier).to receive(:receive).once 36 | subject.__run_action(:receive, :test_data) 37 | end 38 | end 39 | 40 | context "expecting data" do 41 | let(:klass) { CableChannelDataSpec::TestChannel2 } 42 | 43 | it "correctly processes remote method calls" do 44 | expect(verifier).to receive(:receive).with(:test_data).once 45 | subject.__run_action(:receive, :test_data) 46 | end 47 | 48 | it "doesn't cache data" do 49 | expect(verifier).to receive(:receive).with(:test_data).once 50 | expect(verifier).to receive(:receive).with(:another_test_data).once 51 | 52 | subject.__run_action(:receive, :test_data) 53 | subject.__run_action(:receive, :another_test_data) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/cable/channel/identified_by_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CableChannelIdentifiedBySpec 4 | class TestChannel < Rage::Cable::Channel 5 | end 6 | end 7 | 8 | RSpec.describe Rage::Cable::Channel do 9 | subject { CableChannelIdentifiedBySpec::TestChannel.new(nil, nil, identified_by) } 10 | 11 | let(:identified_by) { { current_user: :test_user, current_account: :test_account } } 12 | 13 | before do 14 | CableChannelIdentifiedBySpec::TestChannel.__prepare_id_method(:current_user) 15 | CableChannelIdentifiedBySpec::TestChannel.__prepare_id_method(:current_account) 16 | end 17 | 18 | it "correctly delegates identified_by methods" do 19 | expect(subject.current_user).to eq(:test_user) 20 | expect(subject.current_account).to eq(:test_account) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/cable/channel/unsubscribe_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CableChannelUnsubscribeSpec 4 | class TestChannel < Rage::Cable::Channel 5 | def unsubscribed 6 | verifier.unsubscribed 7 | end 8 | end 9 | 10 | class TestChannel2 < Rage::Cable::Channel 11 | before_unsubscribe do 12 | verifier.before_unsubscribe 13 | end 14 | 15 | after_unsubscribe do 16 | verifier.after_unsubscribe 17 | end 18 | 19 | def unsubscribed 20 | verifier.unsubscribed 21 | end 22 | end 23 | 24 | class TestChannel3 < Rage::Cable::Channel 25 | before_unsubscribe do 26 | verifier.before_unsubscribe 27 | end 28 | 29 | after_unsubscribe do 30 | verifier.after_unsubscribe 31 | end 32 | end 33 | 34 | class TestChannel4 < TestChannel3 35 | def unsubscribed 36 | verifier.unsubscribed 37 | end 38 | end 39 | 40 | class TestChannel5 < Rage::Cable::Channel 41 | before_unsubscribe :verify_before_unsubscribe, if: -> { false } 42 | before_unsubscribe :verify_after_unsubscribe, if: -> { true } 43 | 44 | private 45 | 46 | def verify_before_unsubscribe 47 | verifier.before_unsubscribe 48 | end 49 | 50 | def verify_after_unsubscribe 51 | verifier.after_unsubscribe 52 | end 53 | end 54 | end 55 | 56 | RSpec.describe Rage::Cable::Channel do 57 | subject { klass.tap(&:__register_actions).new(nil, nil, nil).__run_action(:unsubscribed) } 58 | 59 | let(:verifier) { double } 60 | 61 | before do 62 | allow_any_instance_of(Rage::Cable::Channel).to receive(:verifier).and_return(verifier) 63 | end 64 | 65 | context "with the unsubscribed callback" do 66 | let(:klass) { CableChannelUnsubscribeSpec::TestChannel } 67 | 68 | it "correctly runs the unsubscribed callback" do 69 | expect(verifier).to receive(:unsubscribed) 70 | subject 71 | end 72 | end 73 | 74 | context "with before/after unsubscribe" do 75 | let(:klass) { CableChannelUnsubscribeSpec::TestChannel2 } 76 | 77 | it "correctly runs the unsubscribed callback" do 78 | expect(verifier).to receive(:before_unsubscribe) 79 | expect(verifier).to receive(:unsubscribed) 80 | expect(verifier).to receive(:after_unsubscribe) 81 | subject 82 | end 83 | end 84 | 85 | context "with implicit unsubscribed callback" do 86 | let(:klass) { CableChannelUnsubscribeSpec::TestChannel3 } 87 | 88 | it "correctly runs the unsubscribed callback" do 89 | expect(verifier).to receive(:before_unsubscribe) 90 | expect(verifier).to receive(:after_unsubscribe) 91 | subject 92 | end 93 | end 94 | 95 | context "with inheritance" do 96 | let(:klass) { CableChannelUnsubscribeSpec::TestChannel4 } 97 | 98 | it "correctly runs the unsubscribed callback" do 99 | expect(verifier).to receive(:before_unsubscribe) 100 | expect(verifier).to receive(:unsubscribed) 101 | expect(verifier).to receive(:after_unsubscribe) 102 | subject 103 | end 104 | end 105 | 106 | context "with conditionals" do 107 | let(:klass) { CableChannelUnsubscribeSpec::TestChannel5 } 108 | 109 | it "correctly runs the unsubscribed callback" do 110 | expect(verifier).not_to receive(:before_unsubscribe) 111 | expect(verifier).to receive(:after_unsubscribe) 112 | subject 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/cable/connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "domain_name" 4 | 5 | RSpec.describe Rage::Cable::Connection do 6 | describe ".identified_by" do 7 | subject { described_class.new(nil) } 8 | 9 | it "defines accessor methods" do 10 | described_class.identified_by(:test_user) 11 | 12 | expect(subject.test_user).to be_nil 13 | subject.test_user = :user 14 | expect(subject.test_user).to eq(:user) 15 | end 16 | 17 | it "defines channel methods" do 18 | expect(Rage::Cable::Channel).to receive(:__prepare_id_method).with(:test_user).once 19 | described_class.identified_by(:test_user) 20 | end 21 | 22 | context "with identified_by data" do 23 | subject { described_class.new(nil, { test_user: :user_2 }) } 24 | 25 | it "allows to access the data" do 26 | described_class.identified_by(:test_user) 27 | expect(subject.test_user).to eq(:user_2) 28 | end 29 | end 30 | end 31 | 32 | describe "#request" do 33 | subject { described_class.new({ "HTTP_SEC_WEBSOCKET_PROTOCOL" => "test-protocol" }) } 34 | 35 | it "correctly initializes the request object" do 36 | expect(subject.request).to be_a(Rage::Request) 37 | expect(subject.request.headers["Sec-Websocket-Protocol"]).to eq("test-protocol") 38 | end 39 | end 40 | 41 | describe "#cookies" do 42 | subject { described_class.new({ "HTTP_COOKIE" => "user_id=test-user-id" }) } 43 | 44 | it "correctly initializes the cookies object" do 45 | expect(subject.cookies).to be_a(Rage::Cookies) 46 | expect(subject.cookies[:user_id]).to eq("test-user-id") 47 | end 48 | 49 | it "doesn't allow to update cookies" do 50 | expect { subject.cookies[:user_id] = 111 }.to raise_error(/cannot be set/) 51 | end 52 | end 53 | 54 | describe "#params" do 55 | subject { described_class.new({ "QUERY_STRING" => "user_id=test-user-id" }) } 56 | 57 | it "correctly parses parameters" do 58 | expect(subject.params).to be_a(Hash) 59 | expect(subject.params[:user_id]).to eq("test-user-id") 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/code_loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::CodeLoader do 4 | subject { Rage.code_loader } 5 | 6 | let(:app_path) { "#{Dir.tmpdir}/app" } 7 | 8 | before do 9 | allow(Rage.root).to receive(:join).with("app").and_return(Pathname.new(app_path)) 10 | FileUtils.mkdir(app_path) 11 | end 12 | 13 | after do 14 | FileUtils.remove_entry(app_path) 15 | end 16 | 17 | describe "check_updated!" do 18 | context "when there are no files" do 19 | it "returns false on the first call" do 20 | expect(subject.check_updated!).to be(false) 21 | end 22 | 23 | context "when a new file is added" do 24 | before do 25 | subject.check_updated! 26 | File.write("#{app_path}/test.rb", "") 27 | end 28 | 29 | it "returns true" do 30 | expect(subject.check_updated!).to be(true) 31 | end 32 | 33 | it "updates internal state" do 34 | expect(subject.check_updated!).to be(true) 35 | expect(subject.check_updated!).to be(false) 36 | end 37 | end 38 | 39 | context "when existing file is updated" do 40 | before do 41 | File.write("#{app_path}/test.rb", "") 42 | subject.check_updated! 43 | sleep 0.1 44 | FileUtils.touch("#{app_path}/test.rb") 45 | end 46 | 47 | it "returns true" do 48 | expect(subject.check_updated!).to be(true) 49 | end 50 | 51 | it "updates internal state" do 52 | expect(subject.check_updated!).to be(true) 53 | expect(subject.check_updated!).to be(false) 54 | end 55 | end 56 | 57 | context "when existing file is removed" do 58 | before do 59 | File.write("#{app_path}/test.rb", "") 60 | subject.check_updated! 61 | FileUtils.rm("#{app_path}/test.rb") 62 | end 63 | 64 | it "returns true" do 65 | expect(subject.check_updated!).to be(true) 66 | end 67 | 68 | it "updates internal state" do 69 | expect(subject.check_updated!).to be(true) 70 | expect(subject.check_updated!).to be(false) 71 | end 72 | end 73 | end 74 | 75 | context "when there are existing files" do 76 | before do 77 | File.write("#{app_path}/test.rb", "") 78 | FileUtils.mkpath("#{app_path}/models/concerns") 79 | end 80 | 81 | after do 82 | FileUtils.remove_entry("#{app_path}/models/concerns") 83 | end 84 | 85 | context "when a new file is added" do 86 | before do 87 | subject.check_updated! 88 | File.write("#{app_path}/models/concerns/test.rb", "") 89 | end 90 | 91 | it "returns true" do 92 | expect(subject.check_updated!).to be(true) 93 | end 94 | 95 | it "updates internal state" do 96 | expect(subject.check_updated!).to be(true) 97 | expect(subject.check_updated!).to be(false) 98 | end 99 | end 100 | 101 | context "when existing file is updated" do 102 | before do 103 | File.write("#{app_path}/models/concerns/test.rb", "") 104 | subject.check_updated! 105 | sleep 0.1 106 | FileUtils.touch("#{app_path}/models/concerns/test.rb") 107 | end 108 | 109 | it "returns true" do 110 | expect(subject.check_updated!).to be(true) 111 | end 112 | 113 | it "updates internal state" do 114 | expect(subject.check_updated!).to be(true) 115 | expect(subject.check_updated!).to be(false) 116 | end 117 | end 118 | 119 | context "when existing file is removed" do 120 | before do 121 | File.write("#{app_path}/models/concerns/test.rb", "") 122 | subject.check_updated! 123 | FileUtils.rm("#{app_path}/models/concerns/test.rb") 124 | end 125 | 126 | it "returns true" do 127 | expect(subject.check_updated!).to be(true) 128 | end 129 | 130 | it "updates internal state" do 131 | expect(subject.check_updated!).to be(true) 132 | expect(subject.check_updated!).to be(false) 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/controller/api/double_render_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ControllerApiDoubleRenderSpec 4 | class TestController < RageController::API 5 | def show 6 | render json: { message: "hello world" } 7 | render status: :created 8 | end 9 | end 10 | end 11 | 12 | RSpec.describe RageController::API do 13 | let(:klass) { ControllerApiDoubleRenderSpec::TestController } 14 | 15 | it "raises and error" do 16 | expect { run_action(klass, :show) }.to raise_error(/Render was called multiple times in this action/) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/controller/api/headers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ControllerApiHeadersSpec 4 | class TestController < RageController::API 5 | def index 6 | headers["custom_header_1"] = "1" 7 | headers.merge!("custom_header_2" => "22") 8 | 9 | render json: "hello", status: :created 10 | end 11 | 12 | def show 13 | head :ok 14 | end 15 | 16 | def create 17 | headers["custom_header_1"] = "111" 18 | render plain: "hello" 19 | end 20 | end 21 | end 22 | 23 | RSpec.describe RageController::API do 24 | let(:klass) { ControllerApiHeadersSpec::TestController } 25 | 26 | it "correctly sets headers" do 27 | status, headers, body = run_action(klass, :index) 28 | 29 | expect(status).to eq(201) 30 | expect(headers).to include("content-type" => "application/json; charset=utf-8", "custom_header_1" => "1", "custom_header_2" => "22") 31 | expect(body).to eq(["hello"]) 32 | end 33 | 34 | it "doesn't overwrite default headers" do 35 | _, headers, _ = run_action(klass, :show) 36 | expect(headers).not_to include("custom_header_1" => "1", "custom_header_2" => "22") 37 | end 38 | 39 | it "doesn't overwrite previously set headers" do 40 | _, headers, _ = run_action(klass, :create) 41 | expect(headers).to include("custom_header_1" => "111", "content-type" => "text/plain; charset=utf-8") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/controller/api/render_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ControllerApiRenderSpec 4 | class TestController < RageController::API 5 | def render_json 6 | render json: { message: "hello world" } 7 | end 8 | 9 | def head_symbol 10 | head :created 11 | end 12 | 13 | def head_int 14 | head 402 15 | end 16 | 17 | def render_json_with_status 18 | render json: { message: "hello world" }, status: :created 19 | end 20 | 21 | def render_plain_with_status 22 | render plain: "hi", status: 304 23 | end 24 | 25 | def render_plain_with_object 26 | render plain: %w(hi) 27 | end 28 | 29 | def render_status 30 | render status: 202 31 | end 32 | end 33 | end 34 | 35 | RSpec.describe RageController::API do 36 | let(:klass) { ControllerApiRenderSpec::TestController } 37 | let(:json_header) { { "content-type" => "application/json; charset=utf-8" } } 38 | 39 | it "correctly renders json" do 40 | expect(run_action(klass, :render_json)).to eq([200, json_header, ["{\"message\":\"hello world\"}"]]) 41 | end 42 | 43 | it "correctly heads a symbol status" do 44 | expect(run_action(klass, :head_symbol)).to eq([201, json_header, []]) 45 | end 46 | 47 | it "correctly heads an integer status" do 48 | expect(run_action(klass, :head_int)).to eq([402, json_header, []]) 49 | end 50 | 51 | it "correctly renders json with status" do 52 | expect(run_action(klass, :render_json_with_status)).to eq([201, json_header, ["{\"message\":\"hello world\"}"]]) 53 | end 54 | 55 | it "correctly renders text with status" do 56 | expect(run_action(klass, :render_plain_with_status)).to eq([304, { "content-type" => "text/plain; charset=utf-8" }, ["hi"]]) 57 | end 58 | 59 | it "converts objects to string when rendering text" do 60 | expect(run_action(klass, :render_plain_with_object)).to eq([200, { "content-type" => "text/plain; charset=utf-8" }, [%w(hi).to_s]]) 61 | end 62 | 63 | it "correctly renders status" do 64 | expect(run_action(klass, :render_status)).to eq([202, json_header, []]) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/controller/api/session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rbnacl" 4 | require "domain_name" 5 | 6 | RSpec.describe RageController::API do 7 | subject { described_class.new(headers, nil) } 8 | 9 | let(:encoded_session) { "" } 10 | let(:headers) { { "HTTP_COOKIE" => "#{Rage::Session::KEY}=#{encoded_session}" } } 11 | 12 | before do 13 | allow(Rage.config).to receive(:secret_key_base).and_return("rage-test-key") 14 | end 15 | 16 | context "when reading a valid session" do 17 | let(:encoded_session) { "MDDTFjPTyaIdJjZG2C-RJmDPC_5fMyBMTn87Dv7EID3g-OJwakyxFQUhoSlxwqdLRw4npvm08F0=" } 18 | 19 | it "correctly reads values" do 20 | expect(subject.session[:a]).to eq(1) 21 | expect(subject.session[:b]).to eq(22) 22 | expect(subject.session[:c]).to be_nil 23 | end 24 | 25 | it "correctly fetches values" do 26 | expect(subject.session.fetch(:a)).to eq(1) 27 | expect(subject.session.fetch(:b, 33)).to eq(22) 28 | expect(subject.session.fetch(:c, 44)).to eq(44) 29 | expect { subject.session.fetch(:d) }.to raise_error(KeyError) 30 | end 31 | 32 | it "correctly converts to hash" do 33 | expect(subject.session.to_h).to eq({ a: 1, b: 22 }) 34 | end 35 | 36 | it "correctly checks if the session is empty?" do 37 | expect(subject.session).not_to be_empty 38 | end 39 | 40 | it "correctly checks if a key is present in the session" do 41 | expect(subject.session.has_key?(:a)).to be(true) 42 | expect(subject.session.has_key?(:c)).to be(false) 43 | end 44 | 45 | it "correctly implements `dig`" do 46 | expect(subject.session.dig(:a)).to eq(1) 47 | expect(subject.session.dig(:c, :d)).to be_nil 48 | end 49 | end 50 | 51 | context "when reading an invalid session" do 52 | let(:encoded_session) { "MDDTFjPTyaIdJjZG2C-RJmDPC_5fMyBMT-OJwakyxFQUhoSlxwqdLRw4npvm08F0=" } 53 | 54 | it "correctly reads values" do 55 | expect(subject.session[:a]).to be_nil 56 | end 57 | 58 | it "correctly converts to hash" do 59 | expect(subject.session.to_h).to eq({}) 60 | end 61 | 62 | it "correctly checks if the session is empty?" do 63 | expect(subject.session).to be_empty 64 | end 65 | end 66 | 67 | context "when reading an empty session" do 68 | let(:headers) { {} } 69 | 70 | it "correctly reads values" do 71 | expect(subject.session[:a]).to be_nil 72 | end 73 | 74 | it "correctly converts to hash" do 75 | expect(subject.session.to_h).to eq({}) 76 | end 77 | 78 | it "correctly checks if the session is empty?" do 79 | expect(subject.session).to be_empty 80 | end 81 | end 82 | 83 | context "when writing a session" do 84 | let(:new_session) do 85 | _, session_cookie = subject.headers.find { |k, _| k == "Set-Cookie" } 86 | session_value = session_cookie.match(/#{Rage::Session::KEY}=(\S+);/)[1] 87 | 88 | Rage::Cookies::EncryptedJar.load( 89 | Rack::Utils.unescape(session_value, Encoding::UTF_8) 90 | ) 91 | end 92 | 93 | it "correctly updates the session" do 94 | subject.session[:abc] = 123 95 | subject.session[:cde] = 456 96 | 97 | expect(new_session).to eq("{\"abc\":123,\"cde\":456}") 98 | end 99 | 100 | it "correctly deletes keys from the session" do 101 | subject.session[:abc] = 123 102 | subject.session[:cde] = 456 103 | subject.session.delete(:cde) 104 | 105 | expect(new_session).to eq("{\"abc\":123}") 106 | end 107 | 108 | it "correctly clears the session" do 109 | subject.session[:abc] = 123 110 | subject.session.clear 111 | 112 | expect(new_session).to eq("{}") 113 | end 114 | 115 | it "sets correct attributes" do 116 | subject.session[:abc] = 123 117 | _, session_cookie = subject.headers.find { |k, _| k == "Set-Cookie" } 118 | 119 | expect(session_cookie).to match(/.+; HttpOnly; SameSite=Lax/) 120 | end 121 | end 122 | 123 | context "when resetting a session" do 124 | it "calls clear" do 125 | expect(subject.session).to receive(:clear).once 126 | subject.reset_session 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/controller/api/skip_before_actions_inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ControllerApiSkipBeforeActionsInheritanceSpec 4 | class TestController < RageController::API 5 | def index 6 | render plain: "index" 7 | end 8 | end 9 | 10 | class TestController2 < TestController 11 | before_action :setup 12 | 13 | private def setup 14 | verifier.setup 15 | end 16 | end 17 | 18 | class TestController3 < TestController2 19 | skip_before_action :setup, only: :index 20 | 21 | def show 22 | render plain: "show" 23 | end 24 | end 25 | 26 | class TestController4 < TestController3 27 | skip_before_action :setup 28 | end 29 | end 30 | 31 | RSpec.describe RageController::API do 32 | let(:verifier) { double } 33 | 34 | before do 35 | allow_any_instance_of(RageController::API).to receive(:verifier).and_return(verifier) 36 | end 37 | 38 | context "case 1" do 39 | let(:klass) { ControllerApiSkipBeforeActionsInheritanceSpec::TestController } 40 | 41 | it "correctly runs before actions" do 42 | expect(verifier).not_to receive(:setup) 43 | expect(run_action(klass, :index)).to match([200, instance_of(Hash), ["index"]]) 44 | end 45 | end 46 | 47 | context "case 2" do 48 | let(:klass) { ControllerApiSkipBeforeActionsInheritanceSpec::TestController2 } 49 | 50 | it "correctly runs before actions" do 51 | expect(verifier).to receive(:setup).once 52 | expect(run_action(klass, :index)).to match([200, instance_of(Hash), ["index"]]) 53 | end 54 | end 55 | 56 | context "case 3" do 57 | let(:klass) { ControllerApiSkipBeforeActionsInheritanceSpec::TestController3 } 58 | 59 | it "correctly runs before actions" do 60 | expect(verifier).not_to receive(:setup) 61 | expect(run_action(klass, :index)).to match([200, instance_of(Hash), ["index"]]) 62 | end 63 | 64 | it "correctly runs before actions" do 65 | expect(verifier).to receive(:setup).once 66 | expect(run_action(klass, :show)).to match([200, instance_of(Hash), ["show"]]) 67 | end 68 | end 69 | 70 | context "case 4" do 71 | let(:klass) { ControllerApiSkipBeforeActionsInheritanceSpec::TestController4 } 72 | 73 | it "correctly runs before actions" do 74 | expect(verifier).not_to receive(:setup) 75 | expect(run_action(klass, :index)).to match([200, instance_of(Hash), ["index"]]) 76 | end 77 | 78 | it "correctly runs before actions" do 79 | expect(verifier).not_to receive(:setup) 80 | expect(run_action(klass, :show)).to match([200, instance_of(Hash), ["show"]]) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/env_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Env do 4 | subject { described_class.new(env) } 5 | 6 | context "with standard env" do 7 | let(:env) { "development" } 8 | 9 | it "correctly responds to `development?`" do 10 | expect(subject).to be_development 11 | end 12 | 13 | it "correctly responds to `==`" do 14 | expect(subject).to eq("development") 15 | end 16 | 17 | it "correctly responds to `to_s`" do 18 | expect(subject.to_s).to eq("development") 19 | end 20 | 21 | it "correctly responds to `to_sym`" do 22 | expect(subject.to_sym).to eq(:development) 23 | end 24 | 25 | it "correctly responds to `production?`" do 26 | expect(subject).not_to be_production 27 | end 28 | 29 | it "correctly responds to non-standard methods" do 30 | expect(subject).not_to be_staging2 31 | end 32 | 33 | it "correctly defines methods" do 34 | expect(subject).to respond_to(:development?) 35 | expect(subject).to respond_to(:production?) 36 | expect(subject).to respond_to(:staging2?) 37 | end 38 | 39 | it "raises on missing methods" do 40 | expect { subject.development }.to raise_error(NoMethodError) 41 | end 42 | end 43 | 44 | context "with non-standard env" do 45 | let(:env) { "staging2" } 46 | 47 | it "correctly responds to `development?`" do 48 | expect(subject).not_to be_development 49 | end 50 | 51 | it "correctly responds to `==`" do 52 | expect(subject).to eq("staging2") 53 | end 54 | 55 | it "correctly responds to `to_s`" do 56 | expect(subject.to_s).to eq("staging2") 57 | end 58 | 59 | it "correctly responds to `to_sym`" do 60 | expect(subject.to_sym).to eq(:staging2) 61 | end 62 | 63 | it "correctly responds to `staging2?`" do 64 | expect(subject).to be_staging2 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/fixtures/2kb.txt: -------------------------------------------------------------------------------- 1 | 93100156898608528678699992627818455425535111067986131944996396638193835679437177361724196760522822451077854264744882265721686149342154549125381972442714693696464274873227338193821001558484845930532163713488583787961376844178925719834128646418825872361681199619984281035876848435726476613688589537116232447532465811007614747877217284502933103077179499465418572224034193085100857993884744789356187217988161093156943627422829133282207843998713226833795385216345127237532165811393455172586538928626674872488470892668425219911584843299964201002324307955244201912502370834917606059171861516897357412155883478917119372863959454895545212578323467518916744287110098219766264959269291983074543684312184806727996568273777216268596463174858131737822693905449783093669833454985355229455043527882098566457265877213491579611289304875848660261874644652685043226828217969733526643445812080226282386202554917141308197263559519793435560587630468235844236299276735717466437323189492336752298334656669137201647862843663566198322676043776172529724839146814041942232134834151007629839246432197616636122257465097454721532772754806126941868897997100498339822131392234513631756213595416583555865888144579686345690506697582858974632127138851439164877584614952231563265159864289566390602491241481975293263834531759510615847561438663186978614424155662089491262074769990542947041249513538638448157962686165819671001636241489336815447030638355922223361137938613802675984962150377523553474617135727961239684722217634599275959979424451589828971588736251657164758251511408475433492258253787976971720976083786647363791528876176562521304682826931553624139827083999485988594426266858745678748103576538433223437714799509656846322512884584858818593265433504124559034988685441058857776462197879492362793706231280626544058434991162894574099729542992607689167914833824028618959701352595246812231936549166938474393153673727957874311253186775974106444342316518739164113728814805872166166973934868782100738989351965969991353978278091298462567049596160376554158311519884261770152423648818816061256758239273648194488628873352963477523197762271716363886566532577867214840194912823918463368639532142423497376647337795710068112426100601569309134234840246776798555898025805082845037294727532711261376918470589838984397968057872066105618753856294816985341710 -------------------------------------------------------------------------------- /spec/fixtures/700b.txt: -------------------------------------------------------------------------------- 1 | 671699186786817023271825422836801742145838623339828811693196707593359974585517601984387399685871523958465928710073891924231461674342543676479616647499725869164492961619877430842537948247904674605345940194167865768396396681893814191205585964075354178355019169211969102670215331879686515910070264755455064305580213531458492281583275158680478467166034222100995237157191668392366969624130537316339181079646091758989545658426014342426307511656026404677256718663847488432751903939355124187130725386106228721520224646978069873242531002170742639651731728256064594075656063978324478945468533198830148189622534997789655151482059183531296959760651773144299486959399352542394773837068683456653792584939990541036239361 -------------------------------------------------------------------------------- /spec/integration/cable_redis_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "websocket-client-simple" 4 | 5 | RSpec.describe "Cable Redis" do 6 | before :all do 7 | skip("skipping cable redis tests") unless ENV["ENABLE_EXTERNAL_TESTS"] == "true" 8 | end 9 | 10 | before :all do 11 | launch_server(env: { "ENABLE_REDIS_ADAPTER" => "1" }) 12 | end 13 | 14 | after :all do 15 | stop_server 16 | end 17 | 18 | let(:subscribe_message) { { identifier: { client: "1", channel: "TimeChannel" }.to_json, command: "subscribe" }.to_json } 19 | 20 | it "broadcasts messages using the adapter" do 21 | client = with_websocket_connection("ws://localhost:3000/cable?user_id=1", headers: { Origin: "localhost:3000" }) 22 | client.send(subscribe_message) 23 | 24 | Bundler.with_unbundled_env do 25 | system({ "ENABLE_REDIS_ADAPTER" => "1" }, "ruby -e 'require_relative(\"config/application\"); Rage.cable.broadcast(\"current_time\", { message: \"hello from another process\" })'", chdir: "spec/integration/test_app") 26 | end 27 | 28 | expect(client.messages.last).to include("hello from another process") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/integration/file_server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http" 4 | 5 | RSpec.describe "File server" do 6 | before :all do 7 | skip("skipping file server tests") unless ENV["ENABLE_EXTERNAL_TESTS"] == "true" 8 | end 9 | 10 | subject { http.get(url) } 11 | let(:http) { HTTP } 12 | let(:url) { "http://localhost:3000/test.txt" } 13 | 14 | context "with file server disabled" do 15 | before :all do 16 | launch_server 17 | end 18 | 19 | after :all do 20 | stop_server 21 | end 22 | 23 | it "doesn't allow to access public assets" do 24 | expect(subject.code).to eq(404) 25 | end 26 | end 27 | 28 | context "with file server enabled" do 29 | before :all do 30 | launch_server(env: { "ENABLE_FILE_SERVER" => "1" }) 31 | end 32 | 33 | after :all do 34 | stop_server 35 | end 36 | 37 | it "allows to access public assets" do 38 | expect(subject.code).to eq(200) 39 | expect(subject.to_s).to eq("ABCDEFGHIJKLMNOPQRSTUVWXYZ\n") 40 | end 41 | 42 | it "returns correct headers" do 43 | expect(subject.headers["content-length"]).to eq("27") 44 | expect(subject.headers["content-type"]).to eq("text/plain") 45 | expect(subject.headers["etag"]).not_to be_empty 46 | expect(subject.headers["last-modified"]).not_to be_empty 47 | end 48 | 49 | it "fallbacks to application routes" do 50 | response = HTTP.get("http://localhost:3000/get") 51 | expect(response.code).to eq(200) 52 | expect(response.to_s).to eq("i am a get response") 53 | end 54 | 55 | context "with valid range" do 56 | let(:http) { HTTP.headers(range: "bytes=5-9") } 57 | 58 | it "returns correct response" do 59 | expect(subject.code).to eq(206) 60 | expect(subject.to_s).to eq("FGHIJ") 61 | end 62 | 63 | it "returns correct headers" do 64 | expect(subject.headers["content-length"]).to eq("5") 65 | expect(subject.headers["content-range"]).to eq("bytes 5-9/27") 66 | end 67 | end 68 | 69 | context "with invalid range" do 70 | let(:http) { HTTP.headers(range: "bytes=5-100") } 71 | 72 | it "returns correct response" do 73 | expect(subject.code).to eq(416) 74 | end 75 | 76 | it "returns correct headers" do 77 | expect(subject.headers["content-range"]).to eq("bytes */27") 78 | end 79 | end 80 | 81 | context "with If-None-Match" do 82 | let(:http) { HTTP.headers(if_none_match: etag) } 83 | 84 | context "with valid etag" do 85 | let(:etag) { HTTP.get(url).headers["etag"] } 86 | 87 | it "returns correct response" do 88 | expect(subject.code).to eq(304) 89 | end 90 | end 91 | 92 | context "with invalid etag" do 93 | let(:etag) { "invalid-etag" } 94 | 95 | it "returns correct response" do 96 | expect(subject.code).to eq(200) 97 | expect(subject.to_s).to eq("ABCDEFGHIJKLMNOPQRSTUVWXYZ\n") 98 | end 99 | end 100 | end 101 | 102 | context "with If-Range" do 103 | let(:http) { HTTP.headers(range: "bytes=5-9", if_range: etag) } 104 | 105 | context "with valid etag" do 106 | let(:etag) { HTTP.get(url).headers["etag"] } 107 | 108 | it "returns correct response" do 109 | expect(subject.code).to eq(206) 110 | expect(subject.to_s).to eq("FGHIJ") 111 | end 112 | end 113 | 114 | context "with invalid etag" do 115 | let(:etag) { "invalid-etag" } 116 | 117 | it "returns correct status code" do 118 | expect(subject.code).to eq(200) 119 | expect(subject.to_s).to eq("ABCDEFGHIJKLMNOPQRSTUVWXYZ\n") 120 | end 121 | end 122 | end 123 | 124 | context "with URL outside public directory" do 125 | let(:url) { "http://localhost:3000/../Gemfile" } 126 | 127 | it "returns correct status code" do 128 | expect(subject.code).to eq(404) 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/integration/request_id_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http" 4 | 5 | RSpec.describe "Request ID" do 6 | before :all do 7 | skip("skipping end-to-end tests") unless ENV["ENABLE_EXTERNAL_TESTS"] == "true" 8 | end 9 | 10 | let(:logs) { File.readlines("spec/integration/test_app/log/development.log") } 11 | 12 | context "without the RequestID middleware" do 13 | before :all do 14 | launch_server 15 | end 16 | 17 | after :all do 18 | stop_server 19 | end 20 | 21 | it "uses an internal request ID" do 22 | response = HTTP.get("http://localhost:3000/get_request_id") 23 | id = response.to_s 24 | 25 | expect(id.size).to eq(16) 26 | expect(logs.last).to start_with("[#{id}]") 27 | expect(response.headers["x-request-id"]).to be_nil 28 | end 29 | 30 | it "ignores the X-Request-Id header" do 31 | x_request_id = "my-test-request-id" 32 | response = HTTP.headers("X-Request-Id" => x_request_id).get("http://localhost:3000/get_request_id") 33 | 34 | expect(response.to_s).not_to eq(x_request_id) 35 | expect(response.headers["x-request-id"]).to be_nil 36 | end 37 | end 38 | 39 | context "with the RequestID middleware" do 40 | before :all do 41 | launch_server(env: { "ENABLE_REQUEST_ID_MIDDLEWARE" => "1" }) 42 | end 43 | 44 | after :all do 45 | stop_server 46 | end 47 | 48 | it "uses an internal request ID if X-Request-Id is not submitted" do 49 | response = HTTP.get("http://localhost:3000/get_request_id") 50 | id = response.to_s 51 | 52 | expect(id.size).to eq(16) 53 | expect(logs.last).to start_with("[#{id}]") 54 | expect(response.headers["x-request-id"]).to eq(id) 55 | end 56 | 57 | it "uses the X-Request-Id value if it is submitted" do 58 | x_request_id = "my-test-request-id" 59 | response = HTTP.headers("X-Request-Id" => x_request_id).get("http://localhost:3000/get_request_id") 60 | 61 | expect(response.to_s).to eq(x_request_id) 62 | expect(logs.last).to start_with("[#{x_request_id}]") 63 | expect(response.headers["x-request-id"]).to eq(x_request_id) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/integration/test_app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rage-rb" 4 | gem "alba", "~> 3.0" 5 | gem "prism" 6 | gem "redis-client" 7 | 8 | # Get 50% to 150% boost when parsing JSON. 9 | # Rage will automatically use FastJsonparser if it is available. 10 | # gem "fast_jsonparser" 11 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/channels/multiply_numbers_channel.rb: -------------------------------------------------------------------------------- 1 | class MultiplyNumbersChannel < RageCable::Channel 2 | def subscribed 3 | reject unless params[:multiplier] 4 | end 5 | 6 | def receive(data) 7 | transmit({ result: data["i"].to_i * params[:multiplier].to_i }) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/channels/rage_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module RageCable 2 | class Channel < Rage::Cable::Channel 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/channels/rage_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module RageCable 2 | class Connection < Rage::Cable::Connection 3 | identified_by :current_user 4 | 5 | def connect 6 | user_id = params[:user_id] 7 | 8 | if user_id 9 | self.current_user = user_id 10 | else 11 | reject_unauthorized_connection 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/channels/time_channel.rb: -------------------------------------------------------------------------------- 1 | class TimeChannel < RageCable::Channel 2 | def subscribed 3 | stream_from "current_time" 4 | transmit({ sending_current_time: Time.now.to_i }) 5 | end 6 | 7 | def what_time_is_it 8 | transmit({ transmitting_current_time: Time.now.to_i }) 9 | end 10 | 11 | def sync_time 12 | broadcast("current_time", { broadcasting_current_time: Time.now.to_i, message: "initiated by user #{current_user}" }) 13 | end 14 | 15 | def remote_sync_time(data) 16 | sleep 1 17 | transmit({ message: "synced from #{data["remote"]}" }) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/api/base_controller.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | class BaseController < RageController::API 3 | # @version 2.0.0 4 | # @title My Test API 5 | # @auth authenticate_user 6 | 7 | before_action :authenticate_user 8 | 9 | private 10 | 11 | def authenticate_user 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/api/v1/users_controller.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | module V1 3 | class UsersController < BaseController 4 | # Returns the list of all users. 5 | # @description Test 6 | # description 7 | # for 8 | # the 9 | # method. 10 | # @response [UserResource] 11 | def index 12 | end 13 | 14 | # Returns a specific user. 15 | # @response ::UserResource 16 | # @response 404 17 | def show 18 | end 19 | 20 | # Creates a user. 21 | # @request { user: { name: String, email: String, password: String } } 22 | # @response Api::V1::UserResource 23 | def create 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/api/v2/users_controller.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | module V2 3 | class UsersController < BaseController 4 | skip_before_action :authenticate_user, only: :index 5 | 6 | # Returns the list of all users. 7 | # @description Test description. 8 | # @response Array 9 | def index 10 | end 11 | 12 | # Returns a specific user. 13 | # @response UserResource 14 | # @deprecated 15 | def show 16 | end 17 | 18 | # @private 19 | # Creates a user. 20 | def create 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/api/v3/users_controller.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | module V3 3 | class UsersController < BaseController 4 | # Returns a specific user. 5 | # @response 200 #/components/schemas/V3_User 6 | # @response 404 #/components/responses/404NotFound 7 | def show 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < RageController::API 2 | def get 3 | render plain: "i am a get response" 4 | end 5 | 6 | def post 7 | render plain: "i am a post response" 8 | end 9 | 10 | def put 11 | render plain: "i am a put response" 12 | end 13 | 14 | def patch 15 | render plain: "i am a patch response" 16 | end 17 | 18 | def delete 19 | render plain: "i am a delete response" 20 | end 21 | 22 | def empty 23 | end 24 | 25 | def raise_error 26 | raise "1155 test error" 27 | end 28 | 29 | def get_request_id 30 | render plain: request.request_id 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/async_controller.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | 3 | class AsyncController < RageController::API 4 | def sum 5 | i1 = Net::HTTP.get(URI("#{ENV["TEST_HTTP_URL"]}/instant-http-get?i=5.7")) 6 | i2 = Net::HTTP.get(URI("#{ENV["TEST_HTTP_URL"]}/instant-http-get?i=3.4")) 7 | i3, i4 = Fiber.await([ 8 | Fiber.schedule { Net::HTTP.get(URI("#{ENV["TEST_HTTP_URL"]}/instant-http-get?i=1.8")) }, 9 | Fiber.schedule { Net::HTTP.get(URI("#{ENV["TEST_HTTP_URL"]}/instant-http-get?i=8.3")) } 10 | ]) 11 | 12 | render plain: i1.to_i + i2.to_i + i3.to_i + i4.to_i 13 | end 14 | 15 | def long 16 | response = Net::HTTP.get(URI("#{ENV["TEST_HTTP_URL"]}/long-http-get?i=#{params[:i]}")) 17 | render plain: response 18 | end 19 | 20 | def empty 21 | Net::HTTP.get(URI("#{ENV["TEST_HTTP_URL"]}/instant-http-get?i=#{rand}")) 22 | end 23 | 24 | def raise_error 25 | f = Fiber.schedule do 26 | sleep 0.1 27 | raise "raised from inner fiber" 28 | end 29 | Fiber.await f 30 | end 31 | 32 | def short_sleep 33 | sleep 0.0001 34 | head :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/before_actions_controller.rb: -------------------------------------------------------------------------------- 1 | class BeforeActionsController < RageController::API 2 | before_action :create_message 3 | before_action :create_timestamp, if: -> { params[:with_timestamp] } 4 | 5 | def get 6 | response = { message: @message } 7 | response[:timestamp] = @timestamp if @timestamp 8 | 9 | render json: response 10 | end 11 | 12 | private 13 | 14 | def create_message 15 | @message = "hello world" 16 | end 17 | 18 | def create_timestamp 19 | @timestamp = 1636466868 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/logs_controller.rb: -------------------------------------------------------------------------------- 1 | class LogsController < RageController::API 2 | def custom 3 | Rage.logger.info "log_1" 4 | Rage.logger.debug "can't see me" 5 | 6 | Rage.logger.tagged("tag_2") do 7 | Rage.logger.warn "log_2" 8 | end 9 | 10 | sleep 0.1 11 | 12 | Rage.logger.with_context(test: true) do 13 | Rage.logger.error "log_3" 14 | end 15 | end 16 | 17 | def fiber 18 | Rage.logger.info "outside_1" 19 | 20 | f = Fiber.schedule do 21 | sleep 0.1 22 | Rage.logger.tagged("in_fiber") do 23 | Rage.logger.info "inside" 24 | end 25 | end 26 | Fiber.await f 27 | 28 | Rage.logger.info "outside_2" 29 | end 30 | 31 | private 32 | 33 | def append_info_to_payload(payload) 34 | if params[:append_info_to_payload] 35 | payload["hello"] = "world" 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/controllers/params_controller.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | require "digest" 3 | 4 | class ParamsController < RageController::API 5 | def digest 6 | render plain: Digest::MD5.hexdigest(params.to_yaml) 7 | end 8 | 9 | def multipart 10 | params_with_digest = params.merge(text_digest: Digest::MD5.hexdigest(params[:text].read)) 11 | render plain: Digest::MD5.hexdigest(params_with_digest.to_yaml) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/resources/api/v1/avatar_resource.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | module V1 3 | class AvatarResource 4 | include Alba::Resource 5 | attributes :url, :updated_at 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/resources/api/v1/user_resource.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | module V1 3 | class UserResource < BaseUserResource 4 | include Alba::Resource 5 | 6 | attributes :id, :name 7 | has_one :avatar 8 | 9 | nested_attribute :address do 10 | attributes :city, :zip, :country 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/resources/base_user_resource.rb: -------------------------------------------------------------------------------- 1 | class BaseUserResource 2 | include Alba::Resource 3 | root_key :user, :users 4 | attributes :email 5 | end 6 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/resources/comment_resource.rb: -------------------------------------------------------------------------------- 1 | class CommentResource 2 | include Alba::Resource 3 | attributes :content, :created_at 4 | end 5 | -------------------------------------------------------------------------------- /spec/integration/test_app/app/resources/user_resource.rb: -------------------------------------------------------------------------------- 1 | class UserResource 2 | include Alba::Resource 3 | 4 | attributes :full_name 5 | has_many :comments 6 | end 7 | -------------------------------------------------------------------------------- /spec/integration/test_app/config.ru: -------------------------------------------------------------------------------- 1 | require_relative "config/application" 2 | 3 | run Rage.application 4 | 5 | map "/cable" do 6 | run Rage.cable.application 7 | end 8 | 9 | map "/publicapi" do 10 | run Rage.openapi.application 11 | end 12 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/application.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "rage" 3 | Bundler.require(*Rage.groups) 4 | 5 | require "rage/all" 6 | 7 | class TestMiddleware 8 | def initialize(app) 9 | @app = app 10 | end 11 | 12 | def call(env) 13 | if env["HTTP_TEST_MIDDLEWARE"] 14 | [206, {}, ["response from middleware"]] 15 | else 16 | @app.call(env) 17 | end 18 | end 19 | end 20 | 21 | Rage.configure do 22 | config.middleware.use TestMiddleware 23 | config.public_file_server.enabled = !!ENV["ENABLE_FILE_SERVER"] 24 | 25 | if ENV["ENABLE_REQUEST_ID_MIDDLEWARE"] 26 | config.middleware.use Rage::RequestId 27 | end 28 | 29 | if ENV["WEBSOCKETS_PROTOCOL"] 30 | config.cable.protocol = ENV["WEBSOCKETS_PROTOCOL"].to_sym 31 | end 32 | end 33 | 34 | require "rage/setup" 35 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: <%= ENV["ENABLE_REDIS_ADAPTER"] ? "redis" : "test" %> 3 | url: <%= ENV["TEST_REDIS_URL"] %> 4 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rage.configure do 2 | # Specify the number of server processes to run. Defaults to number of CPU cores. 3 | config.server.workers_count = 1 4 | 5 | # Specify the port the server will listen on. 6 | config.server.port = 3000 7 | 8 | # Specify the logger 9 | config.logger = Rage::Logger.new("log/development.log") 10 | config.log_level = Logger::INFO 11 | end 12 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rage.configure do 2 | # Specify the number of server processes to run. Defaults to number of CPU cores. 3 | # config.server.workers_count = ENV.fetch("WEB_CONCURRENCY", 1) 4 | 5 | # Specify the port the server will listen on. 6 | config.server.port = 3000 7 | 8 | # Specify the logger 9 | config.logger = Rage::Logger.new("log/production.log") 10 | config.log_level = Logger::INFO 11 | end 12 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rage.configure do 2 | # Specify the number of server processes to run. Defaults to number of CPU cores. 3 | config.server.workers_count = 1 4 | 5 | # Specify the port the server will listen on. 6 | config.server.port = 3000 7 | 8 | # Specify the logger 9 | config.logger = Rage::Logger.new("log/test.log") 10 | end 11 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/initializers/alba.rb: -------------------------------------------------------------------------------- 1 | module TestInflector 2 | module_function 3 | 4 | def camelize(_) 5 | end 6 | 7 | def camelize_lower(_) 8 | end 9 | 10 | def dasherize(_) 11 | end 12 | 13 | def underscore(_) 14 | end 15 | 16 | def classify(string) 17 | case string.to_s 18 | when "avatar" 19 | "Avatar" 20 | when "comments" 21 | "Comment" 22 | end 23 | end 24 | end 25 | 26 | Alba.inflector = TestInflector 27 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/openapi_components.yml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | V3_User: 4 | type: object 5 | properties: 6 | uuid: 7 | type: string 8 | is_admin: 9 | type: boolean 10 | responses: 11 | 404NotFound: 12 | description: The specified resource was not found. 13 | content: 14 | application/json: 15 | schema: 16 | type: object 17 | properties: 18 | code: 19 | type: string 20 | message: 21 | type: string 22 | -------------------------------------------------------------------------------- /spec/integration/test_app/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rage.routes.draw do 2 | root to: ->(env) { [200, {}, "It works!"] } 3 | 4 | get "get", to: "application#get" 5 | post "post", to: "application#post" 6 | put "put", to: "application#put" 7 | patch "patch", to: "application#patch" 8 | delete "delete", to: "application#delete" 9 | get "empty", to: "application#empty" 10 | get "raise_error", to: "application#raise_error" 11 | get "get_request_id", to: "application#get_request_id" 12 | 13 | get "params/digest", to: "params#digest" 14 | post "params/digest", to: "params#digest" 15 | get "params/:id/defaults", to: "params#digest", defaults: { hello: "world" } 16 | post "params/multipart", to: "params#multipart" 17 | 18 | get "async/sum", to: "async#sum" 19 | get "async/long", to: "async#long" 20 | get "async/empty", to: "async#empty" 21 | get "async/raise_error", to: "async#raise_error" 22 | get "async/short_sleep", to: "async#short_sleep" 23 | 24 | get "before_actions/get", to: "before_actions#get" 25 | 26 | get "logs/custom", to: "logs#custom" 27 | get "logs/fiber", to: "logs#fiber" 28 | 29 | mount ->(_) { [200, {}, ""] }, at: "/admin" 30 | 31 | namespace :api do 32 | namespace :v1 do 33 | resources :users, only: %i(index show create) 34 | end 35 | 36 | namespace :v2 do 37 | resources :users, only: %i(index show create) 38 | end 39 | 40 | namespace :v3 do 41 | resources :users, only: :show 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/integration/test_app/lib/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/spec/integration/test_app/lib/.keep -------------------------------------------------------------------------------- /spec/integration/test_app/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rage-rb/rage/86685d80ea99cc97c8f26b7e6378e7380b704e4d/spec/integration/test_app/log/.keep -------------------------------------------------------------------------------- /spec/integration/test_app/public/test.txt: -------------------------------------------------------------------------------- 1 | ABCDEFGHIJKLMNOPQRSTUVWXYZ 2 | -------------------------------------------------------------------------------- /spec/integration/websockets/raw_web_socket_json_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "RawWebSocketJson" do 4 | before :all do 5 | skip("skipping websocket tests") unless ENV["ENABLE_EXTERNAL_TESTS"] == "true" 6 | end 7 | 8 | before :all do 9 | launch_server(env: { "WEBSOCKETS_PROTOCOL" => "raw_websocket_json" }) 10 | end 11 | 12 | after :all do 13 | stop_server 14 | end 15 | 16 | it "rejects a connection with no user_id" do 17 | with_websocket_connection("ws://localhost:3000/cable/time", headers: { Origin: "localhost:3000" }) do |client| 18 | expect(client.messages[0]).to eq({ err: "unauthorized" }.to_json) 19 | end 20 | end 21 | 22 | it "correctly derives channel name from URL" do 23 | with_websocket_connection("ws://localhost:3000/cable/time?user_id=1", headers: { Origin: "localhost:3000" }) do |client| 24 | expect(client).to be_connected 25 | 26 | expect(client.messages.count).to eq(1) 27 | expect(client.messages[0]).to include("sending_current_time") 28 | end 29 | end 30 | 31 | it "correctly derives complex channel name from URL" do 32 | with_websocket_connection("ws://localhost:3000/cable/multiply_numbers?user_id=1&multiplier=1", headers: { Origin: "localhost:3000" }) do |client| 33 | expect(client).to be_connected 34 | expect(client.messages).to be_empty 35 | end 36 | end 37 | 38 | it "doesn't transform channel names as classes" do 39 | with_websocket_connection("ws://localhost:3000/cable/MultiplyNumbersChannel?user_id=1&multiplier=1", headers: { Origin: "localhost:3000" }) do |client| 40 | expect(client).to be_connected 41 | expect(client.messages).to be_empty 42 | end 43 | end 44 | 45 | it "rejects incorrect channel names" do 46 | with_websocket_connection("ws://localhost:3000/cable/incorrect_channel?user_id=1", headers: { Origin: "localhost:3000" }) do |client| 47 | expect(client.messages[0]).to eq({ err: "invalid channel name" }.to_json) 48 | end 49 | end 50 | 51 | it "receives messages from the server" do 52 | with_websocket_connection("ws://localhost:3000/cable/multiply_numbers?user_id=1&multiplier=6", headers: { Origin: "localhost:3000" }) do |client| 53 | client.send({ i: 4 }.to_json) 54 | expect(client.messages[0]).to eq({ result: 24 }.to_json) 55 | end 56 | end 57 | 58 | it "processes pings" do 59 | with_websocket_connection("ws://localhost:3000/cable/multiply_numbers?user_id=1&multiplier=1", headers: { Origin: "localhost:3000" }) do |client| 60 | client.send("ping") 61 | sleep 1 62 | expect(client.messages[0]).to eq("pong") 63 | end 64 | end 65 | 66 | it "correctly rejects subscriptions" do 67 | with_websocket_connection("ws://localhost:3000/cable/multiply_numbers?user_id=1", headers: { Origin: "localhost:3000" }) do |client| 68 | expect(client.messages[0]).to eq({ err: "subscription rejected" }.to_json) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/middleware/request_id_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::RequestId do 4 | subject { described_class.new(app).call(env)[1]["X-Request-Id"] } 5 | 6 | let(:app) { double } 7 | let(:response) { [200, {}, ["test response"]] } 8 | 9 | context "with no X-Request-Id header" do 10 | before do 11 | allow(app).to receive(:call) do |env| 12 | env["rage.request_id"] ||= "internal-request-id" 13 | response 14 | end 15 | end 16 | 17 | let(:env) { {} } 18 | 19 | it "returns the value of rage.request_id" do 20 | expect(subject).to eq("internal-request-id") 21 | end 22 | 23 | context "with empty value" do 24 | let(:env) { { "HTTP_X_REQUEST_ID" => "" } } 25 | 26 | it "returns the value of rage.request_id" do 27 | expect(subject).to eq("internal-request-id") 28 | end 29 | end 30 | end 31 | 32 | context "with X-Request-Id header" do 33 | before do 34 | allow(app).to receive(:call).with(env).and_return(response) 35 | end 36 | 37 | context "with standard value" do 38 | let(:env) { { "HTTP_X_REQUEST_ID" => "test-x-request-id" } } 39 | 40 | it "adds the ID to the env" do 41 | subject 42 | expect(env["rage.request_id"]).to eq("test-x-request-id") 43 | end 44 | 45 | it "adds the ID to the response" do 46 | subject 47 | expect(response[1]["X-Request-Id"]).to eq("test-x-request-id") 48 | end 49 | end 50 | 51 | context "with long value" do 52 | let(:env) { { "HTTP_X_REQUEST_ID" => "test" * 100 } } 53 | 54 | it "adds the truncated ID to the env" do 55 | subject 56 | expect(env["rage.request_id"].size).to eq(255) 57 | end 58 | 59 | it "adds the truncated ID to the response" do 60 | subject 61 | expect(response[1]["X-Request-Id"]).to eq(env["rage.request_id"]) 62 | end 63 | end 64 | 65 | context "with invalid characters" do 66 | let(:env) { { "HTTP_X_REQUEST_ID" => "test x request id" } } 67 | 68 | it "adds the sanitized ID to the env" do 69 | subject 70 | expect(env["rage.request_id"]).to eq("testxrequestid") 71 | end 72 | 73 | it "adds the sanitized ID to the response" do 74 | subject 75 | expect(response[1]["X-Request-Id"]).to eq("testxrequestid") 76 | end 77 | end 78 | 79 | context "with both long value and invalid characters" do 80 | let(:env) { { "HTTP_X_REQUEST_ID" => "test+" * 100 } } 81 | 82 | it "adds the sanitized ID to the env" do 83 | subject 84 | expect(env["rage.request_id"].size).to be < 255 85 | expect(env["rage.request_id"]).not_to include("+") 86 | end 87 | 88 | it "adds the sanitized ID to the response" do 89 | subject 90 | expect(response[1]["X-Request-Id"]).to eq(env["rage.request_id"]) 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/multi_application_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Rage Multi App" do 4 | subject { Rage.multi_application.call(env) } 5 | 6 | let(:env) { { "PATH_INFO" => "/" } } 7 | let(:rails_verifier) { double } 8 | let(:rage_verifier) { double } 9 | 10 | before do 11 | stub_const("Rails", double(application: rails_verifier)) 12 | allow(Rage).to receive(:application).and_return(rage_verifier) 13 | end 14 | 15 | context "with a 200 response" do 16 | let(:rage_response) { [200, {}, []] } 17 | 18 | it "calls Rage app" do 19 | expect(rage_verifier).to receive(:call).with(env).and_return(rage_response) 20 | expect(subject).to eq(rage_response) 21 | end 22 | end 23 | 24 | context "with a 404 response" do 25 | let(:rage_response) { [404, {}, []] } 26 | 27 | it "calls Rage app" do 28 | expect(rage_verifier).to receive(:call).with(env).and_return(rage_response) 29 | expect(subject).to eq(rage_response) 30 | end 31 | end 32 | 33 | context "with an async response" do 34 | let(:rage_response) { [:__http_defer__, Fiber.new {}] } 35 | 36 | it "calls Rage app" do 37 | expect(rage_verifier).to receive(:call).with(env).and_return(rage_response) 38 | expect(subject).to eq(rage_response) 39 | end 40 | end 41 | 42 | context "with an X-Cascade response" do 43 | let(:rage_response) { [200, { "X-Cascade" => "pass" }, []] } 44 | let(:rails_response) { :test_rails_response } 45 | 46 | it "calls both Rage and Rails apps" do 47 | expect(rage_verifier).to receive(:call).with(env).and_return(rage_response) 48 | expect(rails_verifier).to receive(:call).with(env).and_return(rails_response) 49 | expect(subject).to eq(rails_response) 50 | end 51 | end 52 | 53 | context "with Rails internal request" do 54 | let(:env) { { "PATH_INFO" => "/rails/action_mailbox" } } 55 | let(:rage_response) { [200, {}, []] } 56 | let(:rails_response) { :test_rails_response } 57 | 58 | it "calls both Rage and Rails apps" do 59 | expect(rage_verifier).to receive(:call).with(env).and_return(rage_response) 60 | expect(rails_verifier).to receive(:call).with(env).and_return(rails_response) 61 | expect(subject).to eq(rails_response) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/openapi/builder/description_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Builder do 6 | include_context "mocked_classes" 7 | include_context "mocked_rage_routes" 8 | 9 | subject { described_class.new.run } 10 | 11 | describe "@description" do 12 | context "with one-line description" do 13 | let_class("UsersController", parent: RageController::API) do 14 | <<~'RUBY' 15 | # @description Returns the list of all internal and external users. 16 | def index 17 | end 18 | RUBY 19 | end 20 | 21 | let(:routes) do 22 | { "GET /users" => "UsersController#index" } 23 | end 24 | 25 | it "returns correct schema" do 26 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "Returns the list of all internal and external users.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 27 | end 28 | end 29 | 30 | context "with multi-line description" do 31 | let_class("UsersController", parent: RageController::API) do 32 | <<~'RUBY' 33 | # @description Returns the list of users. 34 | # Pass `with_deleted` to include deleted records. 35 | def index 36 | end 37 | RUBY 38 | end 39 | 40 | let(:routes) do 41 | { "GET /users" => "UsersController#index" } 42 | end 43 | 44 | it "returns correct schema" do 45 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "Returns the list of users. Pass `with_deleted` to include deleted records.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 46 | end 47 | end 48 | 49 | context "with summary" do 50 | let_class("UsersController", parent: RageController::API) do 51 | <<~'RUBY' 52 | # Returns the list of users. 53 | # @description The list includes both internal and external users. 54 | def index 55 | end 56 | RUBY 57 | end 58 | 59 | let(:routes) do 60 | { "GET /users" => "UsersController#index" } 61 | end 62 | 63 | it "returns correct schema" do 64 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "Returns the list of users.", "description" => "The list includes both internal and external users.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/openapi/builder/internal_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Builder do 6 | include_context "mocked_classes" 7 | include_context "mocked_rage_routes" 8 | 9 | subject { described_class.new.run } 10 | 11 | describe "@internal" do 12 | context "with an internal comment" do 13 | let_class("UsersController", parent: RageController::API) do 14 | <<~'RUBY' 15 | # @internal this is an internal comment 16 | def index 17 | end 18 | RUBY 19 | end 20 | 21 | let(:routes) do 22 | { "GET /users" => "UsersController#index" } 23 | end 24 | 25 | it "returns correct schema" do 26 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 27 | end 28 | end 29 | 30 | context "with a multi-line internal comment" do 31 | let_class("UsersController", parent: RageController::API) do 32 | <<~'RUBY' 33 | # @internal this 34 | # is 35 | # an 36 | # internal 37 | # comment 38 | def index 39 | end 40 | RUBY 41 | end 42 | 43 | let(:routes) do 44 | { "GET /users" => "UsersController#index" } 45 | end 46 | 47 | it "returns correct schema" do 48 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 49 | end 50 | end 51 | 52 | context "with another tag" do 53 | let_class("UsersController", parent: RageController::API) do 54 | <<~'RUBY' 55 | # @internal this is an internal comment 56 | # @deprecated 57 | def index 58 | end 59 | RUBY 60 | end 61 | 62 | let(:routes) do 63 | { "GET /users" => "UsersController#index" } 64 | end 65 | 66 | it "returns correct schema" do 67 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 68 | end 69 | end 70 | 71 | context "with an empty comment" do 72 | let_class("UsersController", parent: RageController::API) do 73 | <<~'RUBY' 74 | # Returns the list of all users. 75 | # @deprecated 76 | # 77 | # @internal this is an internal comment 78 | def index 79 | end 80 | RUBY 81 | end 82 | 83 | let(:routes) do 84 | { "GET /users" => "UsersController#index" } 85 | end 86 | 87 | it "returns correct schema" do 88 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "Returns the list of all users.", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 89 | end 90 | 91 | it "does not log error" do 92 | expect(Rage::OpenAPI).not_to receive(:__log_warn) 93 | subject 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/openapi/builder/tag_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Builder do 6 | include_context "mocked_classes" 7 | include_context "mocked_rage_routes" 8 | 9 | subject { described_class.new.run } 10 | 11 | before do 12 | allow(Rage.config.openapi).to receive(:tag_resolver).and_return(tag_resolver) 13 | end 14 | 15 | describe "custom tag resolver" do 16 | context "with a custom tag" do 17 | let(:tag_resolver) do 18 | proc { "User_Records" } 19 | end 20 | 21 | let_class("Api::V1::UsersController", parent: RageController::API) do 22 | <<~'RUBY' 23 | def index 24 | end 25 | RUBY 26 | end 27 | 28 | let(:routes) do 29 | { "GET /users" => "Api::V1::UsersController#index" } 30 | end 31 | 32 | it "returns correct schema" do 33 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "User_Records" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["User_Records"], "responses" => { "200" => { "description" => "" } } } } } }) 34 | end 35 | end 36 | 37 | context "with multiple custom tags" do 38 | let(:tag_resolver) do 39 | proc { %w(Users Records) } 40 | end 41 | 42 | let_class("Api::V1::UsersController", parent: RageController::API) do 43 | <<~'RUBY' 44 | def index 45 | end 46 | RUBY 47 | end 48 | 49 | let(:routes) do 50 | { "GET /users" => "Api::V1::UsersController#index" } 51 | end 52 | 53 | it "returns correct schema" do 54 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Records" }, { "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users", "Records"], "responses" => { "200" => { "description" => "" } } } } } }) 55 | end 56 | end 57 | 58 | context "with conditionals" do 59 | let(:tag_resolver) do 60 | proc do |controller, action, default_tag| 61 | if controller.name == "Api::V1::PhotosController" 62 | "UserRecords" 63 | elsif action == :create 64 | "ModifyOperations" 65 | else 66 | default_tag 67 | end 68 | end 69 | end 70 | 71 | let_class("Api::V1::UsersController", parent: RageController::API) do 72 | <<~'RUBY' 73 | def index 74 | end 75 | 76 | def create 77 | end 78 | RUBY 79 | end 80 | 81 | let_class("Api::V1::PhotosController", parent: RageController::API) do 82 | <<~'RUBY' 83 | def index 84 | end 85 | RUBY 86 | end 87 | 88 | let(:routes) do 89 | { 90 | "GET /users" => "Api::V1::UsersController#index", 91 | "POST /users" => "Api::V1::UsersController#create", 92 | "GET /photos" => "Api::V1::PhotosController#index" 93 | } 94 | end 95 | 96 | it "returns correct schema" do 97 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "ModifyOperations" }, { "name" => "UserRecords" }, { "name" => "v1/Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["ModifyOperations"], "responses" => { "200" => { "description" => "" } } } }, "/photos" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["UserRecords"], "responses" => { "200" => { "description" => "" } } } } } }) 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/openapi/builder/title_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Builder do 6 | include_context "mocked_classes" 7 | include_context "mocked_rage_routes" 8 | 9 | subject { described_class.new.run } 10 | 11 | describe "@title" do 12 | context "with a title" do 13 | let_class("UsersController", parent: RageController::API) do 14 | <<~'RUBY' 15 | # @title My Test API 16 | 17 | def index 18 | end 19 | RUBY 20 | end 21 | 22 | let(:routes) do 23 | { "GET /users" => "UsersController#index" } 24 | end 25 | 26 | it "returns correct schema" do 27 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "My Test API" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 28 | end 29 | end 30 | 31 | context "with inherited title" do 32 | let_class("BaseController", parent: RageController::API) do 33 | <<~'RUBY' 34 | # @title My Test API 35 | RUBY 36 | end 37 | 38 | let_class("UsersController", parent: mocked_classes.BaseController) do 39 | <<~'RUBY' 40 | def index 41 | end 42 | RUBY 43 | end 44 | 45 | let(:routes) do 46 | { "GET /users" => "UsersController#index" } 47 | end 48 | 49 | it "returns correct schema" do 50 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "My Test API" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/openapi/builder/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Builder do 6 | include_context "mocked_classes" 7 | include_context "mocked_rage_routes" 8 | 9 | subject { described_class.new.run } 10 | 11 | describe "@version" do 12 | context "with a title" do 13 | let_class("UsersController", parent: RageController::API) do 14 | <<~'RUBY' 15 | # @version 2.3.4 16 | 17 | def index 18 | end 19 | RUBY 20 | end 21 | 22 | let(:routes) do 23 | { "GET /users" => "UsersController#index" } 24 | end 25 | 26 | it "returns correct schema" do 27 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "2.3.4", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 28 | end 29 | end 30 | 31 | context "with inherited title" do 32 | let_class("BaseController", parent: RageController::API) do 33 | <<~'RUBY' 34 | # @version 2.3.4 35 | RUBY 36 | end 37 | 38 | let_class("UsersController", parent: mocked_classes.BaseController) do 39 | <<~'RUBY' 40 | def index 41 | end 42 | RUBY 43 | end 44 | 45 | let(:routes) do 46 | { "GET /users" => "UsersController#index" } 47 | end 48 | 49 | it "returns correct schema" do 50 | expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "2.3.4", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } }) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/openapi/parsers/ext/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Parsers::Ext::ActiveRecord do 6 | include_context "mocked_classes" 7 | 8 | subject { described_class.new.parse(arg) } 9 | 10 | let_class("User") 11 | let(:arg) { "User" } 12 | 13 | let(:attributes) { {} } 14 | let(:inheritance_column) { nil } 15 | let(:enums) { [] } 16 | 17 | before do 18 | klass = Object.const_get("User") 19 | 20 | allow(klass).to receive(:attribute_types).and_return(attributes) 21 | allow(klass).to receive(:inheritance_column).and_return(inheritance_column) 22 | allow(klass).to receive(:defined_enums).and_return(enums) 23 | end 24 | 25 | context "with no attributes" do 26 | it do 27 | is_expected.to eq({ "type" => "object" }) 28 | end 29 | 30 | context "with collection" do 31 | let(:arg) { "[User]" } 32 | 33 | it do 34 | is_expected.to eq({ "type" => "array", "items" => { "type" => "object" } }) 35 | end 36 | end 37 | end 38 | 39 | context "with attributes" do 40 | let(:attributes) { { age: double(type: :integer), admin: double(type: :boolean), comments: double(type: :json) } } 41 | 42 | it do 43 | is_expected.to eq({ "type" => "object", "properties" => { :age => { "type" => "integer" }, :admin => { "type" => "boolean" }, :comments => { "type" => "object" } } }) 44 | end 45 | 46 | context "with collection" do 47 | let(:arg) { "[User]" } 48 | 49 | it do 50 | is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { :age => { "type" => "integer" }, :admin => { "type" => "boolean" }, :comments => { "type" => "object" } } } }) 51 | end 52 | end 53 | end 54 | 55 | context "with inheritance column" do 56 | let(:attributes) { { age: double(type: :integer), type: double(type: :string) } } 57 | let(:inheritance_column) { :type } 58 | 59 | it do 60 | is_expected.to eq({ "type" => "object", "properties" => { :age => { "type" => "integer" } } }) 61 | end 62 | end 63 | 64 | context "with enum" do 65 | let(:attributes) { { email: double(type: :string) } } 66 | let(:enums) { { status: double(keys: %i(active inactive)) } } 67 | 68 | it do 69 | is_expected.to eq({ "type" => "object", "properties" => { :email => { "type" => "string" }, :status => { "type" => "string", "enum" => [:active, :inactive] } } }) 70 | end 71 | end 72 | 73 | context "with unknown type" do 74 | let(:attributes) { { uuid: double(type: :uuid) } } 75 | 76 | it do 77 | is_expected.to eq({ "type" => "object", "properties" => { :uuid => { "type" => "string" } } }) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/openapi/parsers/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Parsers::Request do 6 | subject { described_class.parse(tag, namespace:) } 7 | 8 | let(:tag) { "test_tag" } 9 | let(:namespace) { double } 10 | 11 | before do 12 | described_class::AVAILABLE_PARSERS.each do |parser_class| 13 | parser = double 14 | allow(parser_class).to receive(:new).with(namespace:).and_return(parser) 15 | allow(parser).to receive(:known_definition?).and_return(false) 16 | end 17 | end 18 | 19 | context "with no matching parsers" do 20 | it { is_expected.to be_nil } 21 | end 22 | 23 | context "with a matching parser" do 24 | let(:parser) { double } 25 | 26 | before do 27 | allow(Rage::OpenAPI::Parsers::YAML).to receive(:new).with(namespace:).and_return(parser) 28 | allow(parser).to receive(:known_definition?).and_return(true) 29 | end 30 | 31 | it do 32 | expect(parser).to receive(:parse).and_return("test_parse_result") 33 | expect(subject).to eq("test_parse_result") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/openapi/parsers/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Parsers::Response do 6 | subject { described_class.parse(tag, namespace:) } 7 | 8 | let(:tag) { "test_tag" } 9 | let(:namespace) { double } 10 | 11 | before do 12 | described_class::AVAILABLE_PARSERS.each do |parser_class| 13 | parser = double 14 | allow(parser_class).to receive(:new).with(namespace:).and_return(parser) 15 | allow(parser).to receive(:known_definition?).and_return(false) 16 | end 17 | end 18 | 19 | context "with no matching parsers" do 20 | it { is_expected.to be_nil } 21 | end 22 | 23 | context "with a matching parser" do 24 | let(:parser) { double } 25 | 26 | before do 27 | allow(Rage::OpenAPI::Parsers::Ext::ActiveRecord).to receive(:new).with(namespace:).and_return(parser) 28 | allow(parser).to receive(:known_definition?).and_return(true) 29 | end 30 | 31 | it do 32 | expect(parser).to receive(:parse).and_return("test_parse_result") 33 | expect(subject).to eq("test_parse_result") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/openapi/parsers/shared_reference_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prism" 4 | 5 | RSpec.describe Rage::OpenAPI::Parsers::SharedReference do 6 | subject { described_class.new.parse(ref) } 7 | 8 | let(:config) do 9 | YAML.safe_load <<~YAML 10 | components: 11 | schemas: 12 | User: 13 | type: object 14 | Error: 15 | type: object 16 | parameters: 17 | offsetParam: 18 | name: offset 19 | responses: 20 | 404NotFound: 21 | description: The specified resource was not found. 22 | ImageResponse: 23 | description: An image. 24 | YAML 25 | end 26 | 27 | before do 28 | allow(Rage::OpenAPI).to receive(:__shared_components).and_return(config) 29 | end 30 | 31 | context "with schema" do 32 | context "with User" do 33 | let(:ref) { "#/components/schemas/User" } 34 | it { is_expected.to eq({ "$ref" => ref }) } 35 | end 36 | 37 | context "with Error" do 38 | let(:ref) { "#/components/schemas/Error" } 39 | it { is_expected.to eq({ "$ref" => ref }) } 40 | end 41 | 42 | context "with invalid key" do 43 | let(:ref) { "#/components/schemas/Ok" } 44 | it { is_expected.to be_nil } 45 | end 46 | end 47 | 48 | context "with parameters" do 49 | context "with offsetParam" do 50 | let(:ref) { "#/components/parameters/offsetParam" } 51 | it { is_expected.to eq({ "$ref" => ref }) } 52 | end 53 | 54 | context "with invalid key" do 55 | let(:ref) { "#/components/parameters/pageParam" } 56 | it { is_expected.to be_nil } 57 | end 58 | end 59 | 60 | context "with responses" do 61 | context "with 404NotFound" do 62 | let(:ref) { "#/components/responses/404NotFound" } 63 | it { is_expected.to eq({ "$ref" => ref }) } 64 | end 65 | 66 | context "with ImageResponse" do 67 | let(:ref) { "#/components/responses/ImageResponse" } 68 | it { is_expected.to eq({ "$ref" => ref }) } 69 | end 70 | 71 | context "with invalid key" do 72 | let(:ref) { "#/components/responses/GenericError" } 73 | it { is_expected.to be_nil } 74 | end 75 | end 76 | 77 | context "with invalid component" do 78 | let(:ref) { "#/components/model/User" } 79 | it { is_expected.to be_nil } 80 | end 81 | 82 | context "with no components" do 83 | let(:config) { {} } 84 | let(:ref) { "#/components/schemas/User" } 85 | 86 | it { is_expected.to be_nil } 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/rage/hooks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hooks do 4 | class ClassWithHooks; include Hooks; end 5 | 6 | subject { ClassWithHooks.new } 7 | 8 | describe "#push_hook" do 9 | it "stores hook by family" do 10 | hook = proc { 1 } 11 | subject.push_hook(hook, :after) 12 | subject.push_hook(1, :after) 13 | subject.push_hook(hook, :before) 14 | subject.push_hook(nil, :before) 15 | 16 | expect(subject.instance_variable_get(:@hooks)).to eq({ after: [hook], before: [hook] }) 17 | end 18 | end 19 | 20 | describe "#run_hooks_for!" do 21 | context "hooks families" do 22 | let(:before_proc) { proc { 2 } } 23 | let(:after_proc) { proc { 1 } } 24 | 25 | before do 26 | subject.push_hook(after_proc, :after) 27 | subject.push_hook(before_proc, :before) 28 | 29 | allow(after_proc).to receive(:call).with(no_args) 30 | allow(before_proc).to receive(:call).with(no_args) 31 | 32 | subject.run_hooks_for!(:after) 33 | end 34 | 35 | it "runs hooks for the given family" do 36 | expect(after_proc).to have_received(:call).with(no_args) 37 | end 38 | 39 | it "clears hooks after run for the executed hooks family" do 40 | hooks = subject.instance_variable_get(:@hooks) 41 | 42 | expect(hooks[:after]).to eq([]) 43 | end 44 | 45 | it "does not run hooks for other families" do 46 | expect(before_proc).not_to have_received(:call).with(no_args) 47 | end 48 | end 49 | 50 | context "hooks context" do 51 | let(:after_proc) { proc { 1 } } 52 | 53 | before do 54 | allow(after_proc).to receive(:call).with(no_args) 55 | end 56 | 57 | context "when context is given" do 58 | let(:context) { Class.new } 59 | 60 | before do 61 | subject.push_hook(after_proc, :after) 62 | 63 | allow(context).to receive(:instance_exec) 64 | allow(context).to receive(:instance_exec).with(after_proc) 65 | 66 | subject.run_hooks_for!(:after, context) 67 | end 68 | 69 | it "executes hook in the context of the provided context" do 70 | expect(context).to have_received(:instance_exec) do |*_, &block| 71 | expect(block).to eq(after_proc) 72 | end 73 | end 74 | end 75 | 76 | context "when context is not given" do 77 | before do 78 | subject.push_hook(after_proc, :after) 79 | 80 | subject.run_hooks_for!(:after) 81 | end 82 | 83 | it "executes hook without context" do 84 | expect(after_proc).to have_received(:call) 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/rage/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Response do 4 | let(:headers) { {} } 5 | let(:body) { {} } 6 | 7 | subject { described_class.new(headers, body) } 8 | 9 | describe "#etag" do 10 | let(:etag) { "1234" } 11 | let(:headers) { { Rage::Response::ETAG_HEADER => etag } } 12 | 13 | it "returns the etag" do 14 | expect(subject.etag).to eq(etag) 15 | end 16 | end 17 | 18 | describe "#etag=" do 19 | context "when passed ETag value is neither String nor nil" do 20 | let(:etag) { {} } 21 | let(:expected_error) { "Expected `String` but `#{etag.class}` is received" } 22 | 23 | it "raises ArgumentError" do 24 | expect { subject.etag = etag }.to raise_error(ArgumentError, expected_error) 25 | end 26 | end 27 | 28 | context "when passed ETag value is nil" do 29 | let(:etag) { "1234" } 30 | let(:headers) { { Rage::Response::ETAG_HEADER => etag } } 31 | 32 | it "sets ETag header to nil" do 33 | expect { subject.etag = nil }. 34 | to change { subject.headers[Rage::Response::ETAG_HEADER] }. 35 | from(etag).to(nil) 36 | end 37 | end 38 | 39 | context "when passed ETag value is String" do 40 | let(:expected_etag) { %(W/"#{Digest::SHA1.hexdigest("1234")}") } 41 | let(:etag) { "1234" } 42 | let(:headers) { { Rage::Response::ETAG_HEADER => etag } } 43 | 44 | it "sets ETag header to be hash of the given value" do 45 | expect { subject.etag = etag }. 46 | to change { subject.headers[Rage::Response::ETAG_HEADER] }. 47 | to(expected_etag) 48 | end 49 | end 50 | end 51 | 52 | describe "#last_modified" do 53 | context "when Last-Modified header is String" do 54 | let(:last_modified) { Time.utc(2025, 5, 5) } 55 | let(:headers) { { Rage::Response::LAST_MODIFIED_HEADER => last_modified.httpdate } } 56 | 57 | it "returns Last-Modified date" do 58 | expect(subject.last_modified).to eq(last_modified.httpdate) 59 | end 60 | end 61 | 62 | context "when Last-Modified header is nil" do 63 | let(:headers) { { Rage::Response::LAST_MODIFIED_HEADER => nil } } 64 | 65 | it "returns nil" do 66 | expect(subject.last_modified).to be_nil 67 | end 68 | end 69 | end 70 | 71 | context "#last_modified=" do 72 | context "when passed Last-Modified value neither Time nor nil" do 73 | let(:last_modified) { {} } 74 | let(:last_modified_header) { Time.utc(2025, 5, 5) } 75 | let(:headers) { { Rage::Response::LAST_MODIFIED_HEADER => last_modified_header } } 76 | 77 | it "raises ArgumentError" do 78 | expect { subject.last_modified = last_modified }.to raise_error(ArgumentError, "Expected `Time` but `#{last_modified.class}` is received") 79 | end 80 | 81 | it "does not change value in headers itself" do 82 | expect { 83 | begin 84 | subject.last_modified = last_modified 85 | rescue ArgumentError 86 | # skip block for testing purposes 87 | end 88 | }.not_to change { subject.headers[Rage::Response::LAST_MODIFIED_HEADER] }.from(last_modified_header) 89 | end 90 | end 91 | 92 | context "when passed Last-Modified value is Time" do 93 | let(:last_modified) { Time.utc(2025, 5, 5) } 94 | 95 | it "sets Last-Modified header" do 96 | expect { subject.last_modified = last_modified }.to change { subject.headers[Rage::Response::LAST_MODIFIED_HEADER] }.to(last_modified.httpdate) 97 | end 98 | end 99 | 100 | context "when passed Last-Modified value is nil" do 101 | let(:last_modified_header) { Time.utc(2025, 5, 5).httpdate } 102 | let(:headers) { { Rage::Response::LAST_MODIFIED_HEADER => last_modified_header } } 103 | 104 | it "sets Last-Modified header to nil" do 105 | expect { subject.last_modified = nil }.to change { subject.headers[Rage::Response::LAST_MODIFIED_HEADER] }. 106 | from(last_modified_header).to(nil) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/router/controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BaseTestController < RageController::API 4 | end 5 | 6 | class TestController < BaseTestController 7 | def index 8 | "test_controller" 9 | end 10 | end 11 | 12 | class TestPhotoTagsController < BaseTestController 13 | def index 14 | "test_photo_tags_controller" 15 | end 16 | end 17 | 18 | module Api 19 | module V1 20 | class TestPhotosController < BaseTestController 21 | def get_all 22 | "api/test_photos_controller" 23 | end 24 | end 25 | end 26 | end 27 | 28 | RSpec.describe Rage::Router::Backend do 29 | it "correctly processes a string handler" do 30 | expect(TestController).to receive(:__register_action).with(:index).and_return(:index) 31 | router.on("GET", "/test", "test#index") 32 | 33 | result, _ = perform_get_request("/test") 34 | expect(result).to eq("test_controller") 35 | end 36 | 37 | it "correctly processes a string handler" do 38 | expect(TestPhotoTagsController).to receive(:__register_action).with(:index).and_return(:index) 39 | router.on("GET", "/test", "test_photo_tags#index") 40 | 41 | result, _ = perform_get_request("/test") 42 | expect(result).to eq("test_photo_tags_controller") 43 | end 44 | 45 | it "correctly processes a string handler" do 46 | expect(Api::V1::TestPhotosController).to receive(:__register_action).with(:get_all).and_return(:get_all) 47 | router.on("GET", "/test", "api/v1/test_photos#get_all") 48 | 49 | result, _ = perform_get_request("/test") 50 | expect(result).to eq("api/test_photos_controller") 51 | end 52 | 53 | it "uses the registered action" do 54 | expect(TestController).to receive(:__register_action).with(:index).and_return(:registered_index) 55 | router.on("GET", "/test", "test#index") 56 | 57 | expect { perform_get_request("/test") }.to raise_error(NoMethodError, /undefined method .registered_index./) 58 | end 59 | 60 | it "raises an error in case the controller doesn't exist" do 61 | expect { 62 | router.on("GET", "/test", "unknown#index") 63 | }.to raise_error("Routing error: could not find the UnknownController class") 64 | end 65 | 66 | it "raises an error in case an action doesn't exist" do 67 | expect(TestController).to receive(:__register_action).with(:unknown).and_call_original 68 | 69 | expect { 70 | router.on("GET", "/test", "test#unknown") 71 | }.to raise_error("The action 'unknown' could not be found for TestController") 72 | end 73 | 74 | it "verifies string handler format" do 75 | expect { 76 | router.on("GET", "/test", "test") 77 | }.to raise_error("Invalid route handler format, expected to match the 'controller#action' pattern") 78 | end 79 | 80 | it "verifies lambda handler format" do 81 | expect { 82 | router.on("GET", "/test", Object) 83 | }.to raise_error("Non-string route handler should respond to `call`") 84 | end 85 | end 86 | 87 | RSpec.describe Rage::Request do 88 | describe "Request" do 89 | describe "#headers" do 90 | it "returns request headers with both meta-variable and original names" do 91 | env = { 92 | "CONTENT_TYPE" => "application/json", 93 | "HTTP_SOME_OTHER_HEADER" => "value", 94 | "HTTP_ACCEPT_LANGUAGE" => "en-US", 95 | "HTTP_VARY" => "Accept-Language" 96 | } 97 | request = Rage::Request.new(env) 98 | 99 | expect(request.headers["Content-Type"]).to eq("application/json") 100 | expect(request.headers["CONTENT_TYPE"]).to eq("application/json") 101 | expect(request.headers["Accept-Language"]).to eq("en-US") 102 | expect(request.headers["HTTP_ACCEPT_LANGUAGE"]).to eq("en-US") 103 | expect(request.headers["non-existent-header"]).to be_nil 104 | expect(request.headers["vary"]).to eq("Accept-Language") 105 | expect(request.headers["VARY"]).to eq("Accept-Language") 106 | expect(request.headers["Vary"]).to eq("Accept-Language") 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/router/defaults_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Router::Backend do 4 | it "correctly processes defaults" do 5 | router.on("GET", "/photos", ->(_) { "all photos" }, defaults: { format: "jpg" }) 6 | 7 | result, params = perform_get_request("/photos") 8 | expect(result).to eq("all photos") 9 | expect(params).to include({ format: "jpg" }) 10 | end 11 | 12 | it "correctly processes defaults with parametric urls" do 13 | router.on("GET", "/photo/:id", ->(_) { "one photo" }, defaults: { format: "jpg" }) 14 | 15 | result, params = perform_get_request("/photo/10") 16 | expect(result).to eq("one photo") 17 | expect(params).to include({ id: "10", format: "jpg" }) 18 | end 19 | 20 | it "prioritizes url params over defaults" do 21 | router.on("GET", "/photo/:id", ->(_) {}, defaults: { id: "20" }) 22 | 23 | _, params = perform_get_request("/photo/30") 24 | expect(params).to include({ id: "30" }) 25 | end 26 | 27 | it "uses defaults when a parameter is missing" do 28 | router.on("GET", "/photo(/:id)", ->(_) {}, defaults: { id: "20" }) 29 | 30 | _, params = perform_get_request("/photo") 31 | expect(params).to include({ id: "20" }) 32 | 33 | _, params = perform_get_request("/photo/30") 34 | expect(params).to include({ id: "30" }) 35 | end 36 | 37 | it "converts default values to string" do 38 | router.on("GET", "/photos", ->(_) {}, defaults: { id: 15 }) 39 | 40 | _, params = perform_get_request("/photos") 41 | expect(params).to include({ id: "15" }) 42 | end 43 | 44 | it "doesn't check defaults when searching for duplicate routes" do 45 | router.on("GET", "/photos", ->(_) {}) 46 | expect { 47 | router.on("GET", "/photos", ->(_) {}, defaults: { id: 15 }) 48 | }.to raise_error(/already declared/) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/router/mount_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Rage::Router::Backend do 2 | it "correctly mounts a Rack app" do 3 | router.mount("/rack_app", ->(_) { :rack_app_response }, %w(GET HEAD)) 4 | 5 | result, _ = perform_get_request("/rack_app") 6 | expect(result).to eq(:rack_app_response) 7 | 8 | result, _ = perform_head_request("/rack_app") 9 | expect(result).to eq(:rack_app_response) 10 | 11 | result, _ = perform_get_request("/rack_app/index") 12 | expect(result).to eq(:rack_app_response) 13 | 14 | result, _ = perform_head_request("/rack_app/index") 15 | expect(result).to eq(:rack_app_response) 16 | 17 | result, _ = perform_post_request("/rack_app") 18 | expect(result).to be_nil 19 | end 20 | 21 | it "updates script name" do 22 | router.mount("/rack_app", ->(env) { env["SCRIPT_NAME"] }, %w(GET)) 23 | 24 | result, _ = perform_get_request("/rack_app") 25 | expect(result).to eq("/rack_app") 26 | 27 | result, _ = perform_get_request("/rack_app/index") 28 | expect(result).to eq("/rack_app") 29 | end 30 | 31 | it "updates path info" do 32 | router.mount("/rack_app", ->(env) { env["PATH_INFO"] }, %w(GET)) 33 | 34 | result, _ = perform_get_request("/rack_app") 35 | expect(result).to eq("/") 36 | 37 | result, _ = perform_get_request("/rack_app/index") 38 | expect(result).to eq("/index") 39 | 40 | result, _ = perform_get_request("/rack_app/get/all/") 41 | expect(result).to eq("/get/all") 42 | end 43 | 44 | it "validates the handler" do 45 | expect { 46 | router.mount("/rack_app", 5, %w(GET)) 47 | }.to raise_error(/should respond to `call`/) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/router/static_routes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Router::Backend do 4 | it "correctly processes a static url" do 5 | router.on("GET", "/photos", ->(_) { "all photos" }) 6 | 7 | result, _ = perform_get_request("/photos") 8 | expect(result).to eq("all photos") 9 | end 10 | 11 | it "correctly processes a static url with multiple sections" do 12 | router.on("GET", "/api/v1/photos", ->(_) { "api photos" }) 13 | 14 | result, _ = perform_get_request("/api/v1/photos") 15 | expect(result).to eq("api photos") 16 | 17 | result, _ = perform_get_request("/api/v1") 18 | expect(result).to be_nil 19 | 20 | result, _ = perform_get_request("/api/v1/photo") 21 | expect(result).to be_nil 22 | 23 | result, _ = perform_get_request("/api/v1/photo/s") 24 | expect(result).to be_nil 25 | end 26 | 27 | it "correctly distinguishes between different methods" do 28 | router.on("GET", "/photos", ->(_) { "photos" }) 29 | 30 | result, _ = perform_post_request("/photos") 31 | expect(result).to be_nil 32 | end 33 | 34 | it "doesn't override routes across methods" do 35 | router.on("GET", "/photos", ->(_) { "get photos" }) 36 | router.on("POST", "/photos", ->(_) { "post photos" }) 37 | router.on("PATCH", "/photos", ->(_) { "patch photos" }) 38 | 39 | result, _ = perform_post_request("/photos") 40 | expect(result).to eq("post photos") 41 | end 42 | 43 | it "performs case-sensitive search" do 44 | router.on("GET", "/photos", ->(_) { "all photos" }) 45 | 46 | result, _ = perform_get_request("/Photos") 47 | expect(result).to be_nil 48 | end 49 | 50 | it "correctly processes urls with dots" do 51 | router.on("GET", "/photos.jpg/all", ->(_) { "jpg photos" }) 52 | 53 | result, _ = perform_get_request("/photos.jpg/all") 54 | expect(result).to eq("jpg photos") 55 | end 56 | 57 | it "correctly processes '::' in urls" do 58 | router.on("GET", "/photos::get", ->(_) { "get photos" }) 59 | router.on("GET", "/api/photos::get/all", ->(_) { "get all photos" }) 60 | 61 | result, _ = perform_get_request("/photos:get") 62 | expect(result).to eq("get photos") 63 | 64 | result, _ = perform_get_request("/api/photos:get/all") 65 | expect(result).to eq("get all photos") 66 | end 67 | 68 | it "correctly processes '-' in urls" do 69 | router.on("GET", "/photos/get-all-photos", ->(_) { "get all photos" }) 70 | router.on("GET", "/api/photos/get-em/all", ->(_) { "api get all photos" }) 71 | 72 | result, _ = perform_get_request("/photos/get-all-photos") 73 | expect(result).to eq("get all photos") 74 | 75 | result, _ = perform_get_request("/api/photos/get-em/all") 76 | expect(result).to eq("api get all photos") 77 | end 78 | 79 | it "raises on duplicates" do 80 | router.on("GET", "/photos", ->(_) {}) 81 | expect { 82 | router.on("GET", "/photos", ->(_) {}) 83 | }.to raise_error("Method 'GET' already declared for route '/photos' with constraints '{}'") 84 | end 85 | 86 | it "correctly processes root urls" do 87 | router.on("GET", "/", ->(_) { "root" }) 88 | 89 | result, _ = perform_get_request("/".dup) # dup to unfreeze 90 | expect(result).to eq("root") 91 | end 92 | 93 | it "ignores slashes at the end of the path" do 94 | router.on("GET", "/photos", ->(_) { "photos" }) 95 | 96 | result, _ = perform_get_request("/photos/".dup) # dup to unfreeze 97 | expect(result).to eq("photos") 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/router/util_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Router::Util do 4 | describe "#path_to_class" do 5 | let(:klass) { Class.new } 6 | 7 | context "with one section" do 8 | before do 9 | stub_const("UsersController", klass) 10 | end 11 | 12 | it "correctly converts string to class" do 13 | expect(described_class.path_to_class("users")).to eq(klass) 14 | end 15 | end 16 | 17 | context "with multiple sections" do 18 | before do 19 | stub_const("AdminUsersController", klass) 20 | end 21 | 22 | it "correctly converts string to class" do 23 | expect(described_class.path_to_class("admin_users")).to eq(klass) 24 | end 25 | end 26 | 27 | context "with a namespace" do 28 | before do 29 | stub_const("Api::UsersController", klass) 30 | end 31 | 32 | it "correctly converts string to class" do 33 | expect(described_class.path_to_class("api/users")).to eq(klass) 34 | end 35 | end 36 | 37 | context "with multiple namespaces" do 38 | before do 39 | stub_const("Admin::Api::V1::UsersController", klass) 40 | end 41 | 42 | it "correctly converts string to class" do 43 | expect(described_class.path_to_class("admin/api/v1/users")).to eq(klass) 44 | end 45 | end 46 | 47 | context "with multiple namespaces and sections" do 48 | before do 49 | stub_const("Api::V1::FavoritePhotosController", klass) 50 | end 51 | 52 | it "correctly converts string to class" do 53 | expect(described_class.path_to_class("api/v1/favorite_photos")).to eq(klass) 54 | end 55 | end 56 | 57 | context "with incorrect name" do 58 | before do 59 | stub_const("UsersController", klass) 60 | end 61 | 62 | it "raises an error" do 63 | expect { described_class.path_to_class("api/v1/users") }.to raise_error(Rage::Errors::RouterError) 64 | end 65 | end 66 | end 67 | 68 | describe "#path_to_name" do 69 | it "correctly converts string to class name" do 70 | expect(described_class).to receive(:path_to_class).once.and_return(double(name: "test-name")) 71 | 72 | expect(described_class.path_to_name("test")).to eq("test-name") 73 | expect(described_class.path_to_name("test")).to eq("test-name") 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/router/wildcard_routes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Rage::Router::Backend do 4 | it "correctly processes a wildcard url" do 5 | router.on("GET", "/photos/:id/*", ->(_) { "photos with wildcard" }) 6 | 7 | result, params = perform_get_request("/photos/1/get-over-here") 8 | expect(result).to eq("photos with wildcard") 9 | expect(params).to include({ id: "1", "*": "get-over-here" }) 10 | end 11 | 12 | it "correctly processes a wildcard url" do 13 | router.on("GET", "/", ->(_) { "root" }) 14 | router.on("GET", "/photos", ->(_) { "photos" }) 15 | router.on("GET", "/*", ->(_) { "root with wildcard" }) 16 | 17 | result, _ = perform_get_request("/") 18 | expect(result).to eq("root") 19 | 20 | result, _ = perform_get_request("/photos") 21 | expect(result).to eq("photos") 22 | 23 | result, _ = perform_get_request("/not-found") 24 | expect(result).to eq("root with wildcard") 25 | end 26 | 27 | it "raises an error if wildcard is not the last character" do 28 | expect { 29 | router.on("GET", "/photos/*/print", ->(_) {}) 30 | }.to raise_error("Wildcard must be the last character in the route") 31 | end 32 | 33 | it "correctly distinguishes between different route types" do 34 | router.on("GET", "/photos/print", ->(_) { "print all photos" }) 35 | router.on("GET", "/photos/print/:id", ->(_) { "print single photo" }) 36 | router.on("GET", "/photos/*", ->(_) { "photos wildcard" }) 37 | 38 | result, _ = perform_get_request("/photos/print") 39 | expect(result).to eq("print all photos") 40 | 41 | result, _ = perform_get_request("/photos/print/2") 42 | expect(result).to eq("print single photo") 43 | 44 | result, _ = perform_get_request("/photos/all") 45 | expect(result).to eq("photos wildcard") 46 | 47 | result, _ = perform_get_request("/get_photos") 48 | expect(result).to be_nil 49 | end 50 | 51 | it "correctly distinguishes between different route types" do 52 | router.on("GET", "/photos/all/print", ->(_) { "print all photos" }) 53 | router.on("GET", "/photos/all/*", ->(_) { "all photos wildcard" }) 54 | router.on("GET", "/photos/:id/print", ->(_) { "print single photo" }) 55 | 56 | result, _ = perform_get_request("/photos/all/print") 57 | expect(result).to eq("print all photos") 58 | 59 | result, params = perform_get_request("/photos/all/24") 60 | expect(result).to eq("all photos wildcard") 61 | expect(params).to include({ "*": "24" }) 62 | 63 | result, params = perform_get_request("/photos/24/print") 64 | expect(result).to eq("print single photo") 65 | expect(params).to include({ id: "24" }) 66 | 67 | result, _ = perform_get_request("/photos/24/all") 68 | expect(result).to be_nil 69 | end 70 | 71 | it "correctly distinguishes between different methods" do 72 | router.on("GET", "*", ->(_) { "default" }) 73 | router.on("GET", "/photos/*", ->(_) { "photos wildcard" }) 74 | 75 | result, _ = perform_post_request("/photos/1") 76 | expect(result).to be_nil 77 | 78 | result, _ = perform_post_request("/default") 79 | expect(result).to be_nil 80 | end 81 | 82 | it "correctly processes encoded urls" do 83 | router.on("GET", "/photos/*", ->(_) { "get a photo" }) 84 | 85 | result, params = perform_get_request("/photos/get+photos%3B+kind%3A+favorites%3B+type%3D*.jpg") 86 | expect(result).to eq("get a photo") 87 | expect(params).to include({ "*": "get photos; kind: favorites; type=*.jpg" }) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/rspec/config.ru: -------------------------------------------------------------------------------- 1 | run Rage.application 2 | -------------------------------------------------------------------------------- /spec/rspec/config/application.rb: -------------------------------------------------------------------------------- 1 | Rage.configure {} 2 | -------------------------------------------------------------------------------- /spec/setup_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "Setup" do 2 | let(:valid_env) { "development" } 3 | let(:invalid_env) { "develop" } 4 | let(:setup_file) { File.expand_path("../lib/rage/setup.rb", __dir__) } 5 | 6 | before do 7 | allow(Rage).to receive(:env).and_return(env) 8 | allow(Rage).to receive(:root).and_return(Pathname.new(File.expand_path("..", __dir__))) 9 | allow(Rage).to receive_message_chain(:code_loader, :setup).and_return(true) 10 | allow(Rage).to receive_message_chain(:config, :run_after_initialize!).and_return(true) 11 | allow(Iodine).to receive(:patch_rack).and_return(true) 12 | end 13 | 14 | context "when environment name is valid" do 15 | let(:env) { valid_env } 16 | 17 | before do 18 | allow_any_instance_of(Object).to receive(:require_relative).with("#{Rage.root}/config/environments/#{Rage.env}").and_return(true) 19 | allow_any_instance_of(Object).to receive(:require_relative).with("#{Rage.root}/config/routes").and_return(true) 20 | end 21 | 22 | it "loads the environment without error" do 23 | expect { load setup_file }.not_to raise_error 24 | end 25 | end 26 | 27 | context "when environment name is invalid" do 28 | let(:env) { invalid_env } 29 | 30 | it "raises a custom error with a meaningful message" do 31 | expect { load setup_file }. 32 | to raise_error(LoadError, "The <#{invalid_env}> environment could not be found. Please check the environment name.") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rage/all" 4 | require_relative "support/integration_helper" 5 | require_relative "support/request_helper" 6 | require_relative "support/controller_helper" 7 | require_relative "support/reactor_helper" 8 | require_relative "support/websocket_helper" 9 | require_relative "support/contexts/mocked_classes" 10 | require_relative "support/contexts/mocked_rage_routes" 11 | require_relative "support/custom_matchers" 12 | 13 | RSpec.configure do |config| 14 | # Uncomment the line below to enable focused mode 15 | # config.filter_run focus: true 16 | 17 | # Enable flags like --only-failures and --next-failure 18 | config.example_status_persistence_file_path = ".rspec_status" 19 | 20 | # Disable RSpec exposing methods globally on `Module` and `main` 21 | config.disable_monkey_patching! 22 | 23 | config.expect_with :rspec do |c| 24 | c.syntax = :expect 25 | end 26 | 27 | config.before(:suite) do 28 | Iodine.patch_rack 29 | end 30 | 31 | config.include IntegrationHelper 32 | config.include RequestHelper 33 | config.include ControllerHelper 34 | config.include ReactorHelper 35 | config.include WebSocketHelper 36 | 37 | config.include_context "mocked_classes", include_shared: true 38 | config.include_context "mocked_rage_routes", include_shared: true 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/contexts/mocked_classes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | RSpec.shared_context "mocked_classes" do 6 | before do 7 | allow(Object).to receive(:const_get).and_call_original 8 | allow(Object).to receive(:const_source_location).and_call_original 9 | end 10 | 11 | def self.mocked_classes 12 | @mocked_classes ||= OpenStruct.new 13 | end 14 | 15 | def self.let_class(class_name, parent: Object, &block) 16 | source = Tempfile.new.tap do |f| 17 | if block 18 | f.write <<~RUBY 19 | class #{class_name} #{"< #{parent.name}" if parent != Object} 20 | #{block.call} 21 | end 22 | RUBY 23 | end 24 | f.close 25 | end 26 | 27 | klass = Class.new(parent, &block) 28 | klass.define_singleton_method(:name) { class_name } 29 | 30 | mocked_classes[class_name] = klass 31 | 32 | before do 33 | allow(Object).to receive(:const_get).with(satisfy { |c| c.to_s == class_name.to_s }).and_return(klass) 34 | allow(Object).to receive(:const_source_location).with(class_name).and_return(source.path) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/support/contexts/mocked_rage_routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "mocked_rage_routes" do 4 | before do 5 | allow(Rage.__router).to receive(:routes) do 6 | routes.map do |method_path_component, controller_action_component| 7 | method, path = method_path_component.split(" ", 2) 8 | controller, action = controller_action_component.split("#", 2) 9 | 10 | { 11 | method:, 12 | path:, 13 | meta: { controller_class: Object.const_get(controller), action: } 14 | } 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/controller_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ControllerHelper 4 | def run_action(controller, action, params: {}, env: {}) 5 | handler = controller.__register_action(action) 6 | controller.new(env, params).public_send(handler) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/custom_matchers.rb: -------------------------------------------------------------------------------- 1 | # define opposite to a_hash_including matcher 2 | RSpec::Matchers.matcher :a_hash_excluding_keys do |*excluded_keys| 3 | match do |actual| 4 | excluded_keys.flatten.none? { |key| actual.key?(key) } 5 | end 6 | 7 | failure_message do |actual| 8 | present_keys = excluded_keys.flatten.select { |key| actual.key?(key) } 9 | "expected hash not to include keys: #{present_keys.join(", ")}, but found them" 10 | end 11 | 12 | failure_message_when_negated do |actual| 13 | "expected hash to include at least one of the keys: #{excluded_keys.flatten.join(", ")}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/integration_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module IntegrationHelper 4 | def launch_server(env: {}) 5 | Bundler.with_unbundled_env do 6 | system("gem build -o rage-local.gem && gem install rage-local.gem --no-document") 7 | system("bundle install", chdir: "spec/integration/test_app") 8 | @pid = spawn(env, "bundle exec rage s", chdir: "spec/integration/test_app") 9 | sleep(2) 10 | end 11 | end 12 | 13 | def stop_server 14 | if @pid 15 | Process.kill(:SIGTERM, @pid) 16 | Process.wait 17 | system("rm spec/integration/test_app/Gemfile.lock") 18 | system("rm spec/integration/test_app/log/development.log") 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/reactor_helper.rb: -------------------------------------------------------------------------------- 1 | module ReactorHelper 2 | ## 3 | # we need Iodine reactor up and running to test the scheduler, but once the reactor is started, it will block until 4 | # stopped, running only the code that is scheduled to run inside it; hence, we first schedule the test to run and 5 | # then set up a periodic task to stop the reactor; only after that the reactor is started; 6 | # the block is expected to return a proc to enable the code inside the reactor to communicate the test result to rspec. 7 | # 8 | def within_reactor(&block) 9 | fiber = nil 10 | 11 | Iodine.defer { fiber = Fiber.schedule { block.call } } 12 | Iodine.run_every(200) { Iodine.stop unless fiber.alive? rescue Iodine.stop } 13 | Iodine.run_after(10_000) { fiber.raise("execution expired") } 14 | 15 | Iodine.threads = Iodine.workers = 1 16 | Iodine.start 17 | 18 | expectation = fiber.__get_result 19 | expectation.call 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/request_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RequestHelper 4 | def router 5 | @router ||= Rage::Router::Backend.new 6 | end 7 | 8 | def perform_get_request(path, host: nil, params: {}) 9 | perform_request("GET", path, host, params) 10 | end 11 | 12 | def perform_head_request(path, host: nil, params: {}) 13 | perform_request("HEAD", path, host, params) 14 | end 15 | 16 | def perform_post_request(path, host: nil, params: {}) 17 | perform_request("POST", path, host, params) 18 | end 19 | 20 | private 21 | 22 | def perform_request(method, path, host, params) 23 | env = { 24 | "REQUEST_METHOD" => method, 25 | "PATH_INFO" => path, 26 | "HTTP_HOST" => host 27 | } 28 | handler = router.lookup(env) 29 | 30 | [handler[:handler].call(env, handler[:params]), params.merge(handler[:params])] if handler 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/websocket_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "websocket-client-simple" 4 | 5 | module WebSocketHelper 6 | def with_websocket_connection(url, headers: {}) 7 | client = WebSocketTestClient.new(url, headers:) 8 | yield client if block_given? 9 | client 10 | end 11 | 12 | class WebSocketTestClient 13 | def initialize(url, headers: {}) 14 | @url = url 15 | @headers = headers 16 | 17 | ws_data = { connected: false, closed: false, heartbeats: [], messages: [] } 18 | 19 | @ws = WebSocket::Client::Simple.connect(@url, headers: @headers) do |ws| 20 | ws.on :open do 21 | ws_data[:connected] = true 22 | ws_data[:closed] = false 23 | end 24 | 25 | ws.on :message do |msg| 26 | list = msg.to_s.include?("ping") ? ws_data[:heartbeats] : ws_data[:messages] 27 | list << msg.to_s 28 | end 29 | 30 | ws.on :close do 31 | ws_data[:closed] = true 32 | end 33 | end 34 | 35 | @ws_data = ws_data 36 | 37 | sleep 0.1 38 | end 39 | 40 | def connected? 41 | @ws.handshake.valid? && @ws_data[:connected] && !@ws_data[:closed] 42 | end 43 | 44 | def send(data) 45 | 2.times do 46 | @ws.send(data) 47 | break 48 | rescue Errno::EBADF 49 | puts "Lost websocket connection! Reconnecting..." 50 | @ws.connect(@url, headers: @headers) 51 | end 52 | 53 | sleep 0.1 54 | end 55 | 56 | def heartbeats 57 | @ws_data[:heartbeats] 58 | end 59 | 60 | def messages 61 | @ws_data[:messages] 62 | end 63 | end 64 | end 65 | --------------------------------------------------------------------------------