├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------