rage-rb 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11dd7f4039089ea1f06eb54c332851c1942f7dc646f48f20a648b9504681d61d
4
- data.tar.gz: ed28d28bcee87dbbb59dcc200540b7b7588d68b3ace8821c55a014005314974a
3
+ metadata.gz: 04ca7e51d4117d534058db889d82d6ce86af9c9edf389a03711baffa3a8bc484
4
+ data.tar.gz: 347fa6296d6fa65d99125146b7f82921a9ae2b99836d6f0191fc31dc135896c7
5
5
  SHA512:
6
- metadata.gz: fa1d951dcd7a0cb63fbcef9801298266f4bf61f515977dd3c311e7ea563df9dfcc383db282386f997257d35d01695168880aa160437166105aa8e73ada028295
7
- data.tar.gz: 7c8e571b08e8987ffec6dd31799382ad0c9da35b42fb7aa0988b146f3efac6e8cd35391410f1e2339191a58dbec81ee8bdb4f78728070fc0d4123a4e2e54f4f0
6
+ metadata.gz: 8490a009689d8dbd9c98f47b01701e80dda65fe96dfa991540adc59dc0dd68859690263a15a6cb3f2f2ad23aa300c8bcf4eb6aed468a69b9997191af15806bae
7
+ data.tar.gz: 7b5d887f78d0d102a9ea92027fe531d61870bed70828a56a4b79c51540c560553dd907c7c27a72867b4adbc241e4e1a4a529b84db991786d6845b457c229bfb2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.11.0] - 2024-12-18
4
+
5
+ ### Added
6
+
7
+ - `Rage::OpenAPI` (#109).
8
+
9
+ ### Fixed
10
+
11
+ - Correctly handle ActiveRecord connections in the environments with `legacy_connection_handling == false` (#108).
12
+
13
+ ## [1.10.1] - 2024-09-17
14
+
15
+ ### Fixed
16
+
17
+ - Patch AR pool even if `Rake` is defined (#105).
18
+
3
19
  ## [1.10.0] - 2024-09-16
4
20
 
5
21
  ### Changed
data/Gemfile CHANGED
@@ -19,4 +19,5 @@ group :test do
19
19
  gem "rbnacl"
20
20
  gem "domain_name"
21
21
  gem "websocket-client-simple"
22
+ gem "prism"
22
23
  end
data/README.md CHANGED
@@ -46,6 +46,11 @@ Start coding!
46
46
 
47
47
  This gem is designed to be a drop-in replacement for Rails in API mode. Public API is expected to fully match Rails.
48
48
 
49
+ A Rage application can operate in two modes:
50
+
51
+ * **Rails Mode**: Integrate Rage into an existing Rails application to improve throughput and better handle traffic spikes. For more information, see [Rails Integration](https://github.com/rage-rb/rage/wiki/Rails-integration).
52
+ * **Standalone Mode**: Build high-performance services with minimal setup using Rage. To get started, run `rage new --help` for more details.
53
+
49
54
  Check out in-depth API docs for more information:
50
55
 
51
56
  - [Controller API](https://rage-rb.pages.dev/RageController/API)
@@ -37,6 +37,10 @@ class Rage::CodeLoader
37
37
  unless Rage.autoload?(:Cable) # the `Cable` component is loaded
38
38
  Rage::Cable.__router.reset
39
39
  end
40
+
41
+ unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
42
+ Rage::OpenAPI.__reset_data_cache
43
+ end
40
44
  end
41
45
 
42
46
  # in Rails mode - reset the routes; everything else will be done by Rails
@@ -49,6 +53,10 @@ class Rage::CodeLoader
49
53
  unless Rage.autoload?(:Cable) # the `Cable` component is loaded
50
54
  Rage::Cable.__router.reset
51
55
  end
56
+
57
+ unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
58
+ Rage::OpenAPI.__reset_data_cache
59
+ end
52
60
  end
53
61
 
54
62
  def reloading?
@@ -122,6 +122,17 @@
122
122
  #
123
123
  # > Allows requests from any origin.
124
124
  #
125
+ # # OpenAPI Configuration
126
+ # • _config.openapi.tag_resolver_
127
+ #
128
+ # > Specifies the proc to build tags for API operations. The proc accepts the controller class, the symbol name of the action, and the default tag built by Rage.
129
+ #
130
+ # > ```ruby
131
+ # config.openapi.tag_resolver = proc do |controller, action, default_tag|
132
+ # # ...
133
+ # end
134
+ # > ```
135
+ #
125
136
  # # Transient Settings
126
137
  #
127
138
  # The settings described in this section should be configured using **environment variables** and are either temporary or will become the default in the future.
@@ -179,6 +190,10 @@ class Rage::Configuration
179
190
  @public_file_server ||= PublicFileServer.new
180
191
  end
181
192
 
193
+ def openapi
194
+ @openapi ||= OpenAPI.new
195
+ end
196
+
182
197
  def internal
183
198
  @internal ||= Internal.new
184
199
  end
@@ -218,6 +233,10 @@ class Rage::Configuration
218
233
  @middlewares = (@middlewares[0..index] + [[new_middleware, args, block]] + @middlewares[index + 1..]).uniq(&:first)
219
234
  end
220
235
 
236
+ def include?(middleware)
237
+ !!find_middleware_index(middleware) rescue false
238
+ end
239
+
221
240
  private
222
241
 
223
242
  def find_middleware_index(middleware)
@@ -264,6 +283,10 @@ class Rage::Configuration
264
283
  attr_accessor :enabled
265
284
  end
266
285
 
286
+ class OpenAPI
287
+ attr_accessor :tag_resolver
288
+ end
289
+
267
290
  # @private
268
291
  class Internal
269
292
  attr_accessor :rails_mode
@@ -12,12 +12,7 @@ class RageController::API
12
12
  raise Rage::Errors::RouterError, "The action '#{action}' could not be found for #{self}" unless method_defined?(action)
13
13
 
14
14
  before_actions_chunk = if @__before_actions
15
- filtered_before_actions = @__before_actions.select do |h|
16
- (!h[:only] || h[:only].include?(action)) &&
17
- (!h[:except] || !h[:except].include?(action))
18
- end
19
-
20
- lines = filtered_before_actions.map do |h|
15
+ lines = __before_actions_for(action).map do |h|
21
16
  condition = if h[:if] && h[:unless]
22
17
  "if #{h[:if]} && !#{h[:unless]}"
23
18
  elsif h[:if]
@@ -38,12 +33,7 @@ class RageController::API
38
33
  end
39
34
 
40
35
  after_actions_chunk = if @__after_actions
41
- filtered_after_actions = @__after_actions.select do |h|
42
- (!h[:only] || h[:only].include?(action)) &&
43
- (!h[:except] || !h[:except].include?(action))
44
- end
45
-
46
- lines = filtered_after_actions.map! do |h|
36
+ lines = __after_actions_for(action).map do |h|
47
37
  condition = if h[:if] && h[:unless]
48
38
  "if #{h[:if]} && !#{h[:unless]}"
49
39
  elsif h[:if]
@@ -319,6 +309,31 @@ class RageController::API
319
309
  @__wrap_parameters_options = { include:, exclude: }
320
310
  end
321
311
 
312
+ # @private
313
+ def __before_action_exists?(name)
314
+ @__before_actions.any? { |h| h[:name] == name && !h[:around] }
315
+ end
316
+
317
+ # @private
318
+ def __before_actions_for(action_name)
319
+ return [] unless @__before_actions
320
+
321
+ @__before_actions.select do |h|
322
+ (!h[:only] || h[:only].include?(action_name)) &&
323
+ (!h[:except] || !h[:except].include?(action_name))
324
+ end
325
+ end
326
+
327
+ # @private
328
+ def __after_actions_for(action_name)
329
+ return [] unless @__after_actions
330
+
331
+ @__after_actions.select do |h|
332
+ (!h[:only] || h[:only].include?(action_name)) &&
333
+ (!h[:except] || !h[:except].include?(action_name))
334
+ end
335
+ end
336
+
322
337
  private
323
338
 
324
339
  # used by `before_action` and `after_action`
@@ -7,11 +7,13 @@ if defined?(ActiveSupport::IsolatedExecutionState)
7
7
  ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
8
8
  end
9
9
 
10
- # patch Active Record 6.0 to accept the role argument
11
- if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("6.1")
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")
12
14
  %i(active_connections? connection_pool_list clear_active_connections!).each do |m|
13
- ActiveRecord::Base.connection_handler.define_singleton_method(m) do |_ = nil|
14
- super()
15
+ ActiveRecord::Base.connection_handler.define_singleton_method(m) do |role = nil|
16
+ role == :all ? super() : super(role)
15
17
  end
16
18
  end
17
19
  end
@@ -94,6 +96,6 @@ if defined?(ActiveRecord) && !Rage.config.internal.rails_mode && (database_url |
94
96
  end
95
97
 
96
98
  # patch `ActiveRecord::ConnectionPool`
97
- if defined?(ActiveRecord) && !defined?(Rake) && Rage.config.internal.patch_ar_pool?
99
+ if defined?(ActiveRecord) && Rage.config.internal.patch_ar_pool?
98
100
  Rage.patch_active_record_connection_pool
99
101
  end
@@ -0,0 +1,84 @@
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
+ def initialize(namespace: nil)
15
+ @namespace = namespace.to_s if namespace
16
+
17
+ @collectors_cache = {}
18
+ @nodes = Rage::OpenAPI::Nodes::Root.new
19
+ @routes = Rage.__router.routes.group_by { |route| route[:meta][:controller_class] }
20
+ end
21
+
22
+ def run
23
+ parser = Rage::OpenAPI::Parser.new
24
+
25
+ @routes.each do |controller, routes|
26
+ next if skip_controller?(controller)
27
+
28
+ parent_nodes = fetch_ancestors(controller).map do |klass|
29
+ @nodes.new_parent_node(klass) { |node| parser.parse_dangling_comments(node, parse_class(klass).dangling_comments) }
30
+ end
31
+
32
+ routes.each do |route|
33
+ action = route[:meta][:action]
34
+
35
+ method_comments = fetch_ancestors(controller).filter_map { |klass|
36
+ parse_class(klass).method_comments(action)
37
+ }.first
38
+
39
+ method_node = @nodes.new_method_node(controller, action, parent_nodes)
40
+ method_node.http_method, method_node.http_path = route[:method], route[:path]
41
+
42
+ parser.parse_method_comments(method_node, method_comments)
43
+ end
44
+
45
+ rescue ParsingError
46
+ Rage::OpenAPI.__log_warn "skipping #{controller.name} because of parsing error"
47
+ next
48
+ end
49
+
50
+ Rage::OpenAPI::Converter.new(@nodes).run
51
+ end
52
+
53
+ private
54
+
55
+ def skip_controller?(controller)
56
+ should_skip_controller = controller.nil? || !controller.ancestors.include?(RageController::API)
57
+ should_skip_controller ||= !controller.name.start_with?(@namespace) if @namespace
58
+
59
+ should_skip_controller
60
+ end
61
+
62
+ def fetch_ancestors(controller)
63
+ controller.ancestors.take_while { |klass| klass != RageController::API }
64
+ end
65
+
66
+ def parse_class(klass)
67
+ @collectors_cache[klass] ||= begin
68
+ source_path, _ = Object.const_source_location(klass.name)
69
+ ast = Prism.parse_file(source_path)
70
+
71
+ raise ParsingError if ast.errors.any?
72
+
73
+ # save the "comment => file" association
74
+ ast.comments.each do |comment|
75
+ comment.location.define_singleton_method(:__source_path) { source_path }
76
+ end
77
+
78
+ collector = Rage::OpenAPI::Collector.new(ast.comments)
79
+ ast.value.accept(collector)
80
+
81
+ collector
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,43 @@
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
+ def initialize(comments)
9
+ @comments = comments.dup
10
+ @method_comments = {}
11
+ end
12
+
13
+ def dangling_comments
14
+ @comments
15
+ end
16
+
17
+ def method_comments(method_name)
18
+ @method_comments[method_name.to_s]
19
+ end
20
+
21
+ def visit_def_node(node)
22
+ method_comments = []
23
+ start_line = node.location.start_line - 1
24
+
25
+ loop do
26
+ comment_i = @comments.find_index { |comment| comment.location.start_line == start_line }
27
+ if comment_i
28
+ comment = @comments.delete_at(comment_i)
29
+ method_comments << comment
30
+ start_line -= 1
31
+ end
32
+
33
+ break unless comment
34
+ end
35
+
36
+ @method_comments[node.name.to_s] = method_comments.reverse
37
+
38
+ # reject comments inside methods
39
+ @comments.reject! do |comment|
40
+ comment.location.start_line >= node.location.start_line && comment.location.start_line <= node.location.end_line
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Converter
4
+ def initialize(nodes)
5
+ @nodes = nodes
6
+ @used_tags = Set.new
7
+ @used_security_schemes = Set.new
8
+
9
+ @spec = {
10
+ "openapi" => "3.0.0",
11
+ "info" => {},
12
+ "components" => {},
13
+ "tags" => [],
14
+ "paths" => {}
15
+ }
16
+ end
17
+
18
+ def run
19
+ @spec["info"] = {
20
+ "version" => @nodes.version || "1.0.0",
21
+ "title" => @nodes.title || build_app_name
22
+ }
23
+
24
+ @spec["paths"] = @nodes.leaves.each_with_object({}) do |node, memo|
25
+ next if node.private || node.parents.any?(&:private)
26
+
27
+ path_parameters = []
28
+ path = node.http_path.gsub(/:(\w+)/) do
29
+ path_parameters << $1
30
+ "{#{$1}}"
31
+ end
32
+
33
+ unless memo.key?(path)
34
+ memo[path] = {}
35
+ path_parameters.each do |parameter|
36
+ (memo[path]["parameters"] ||= []) << {
37
+ "in" => "path",
38
+ "name" => parameter,
39
+ "required" => true,
40
+ "schema" => { "type" => parameter.end_with?("id") ? "integer" : "string" }
41
+ }
42
+ end
43
+ end
44
+
45
+ method = node.http_method.downcase
46
+ memo[path][method] = {
47
+ "summary" => node.summary || "",
48
+ "description" => node.description&.join(" ") || "",
49
+ "deprecated" => !!(node.deprecated || node.parents.any?(&:deprecated)),
50
+ "security" => build_security(node),
51
+ "tags" => build_tags(node)
52
+ }
53
+
54
+ memo[path][method]["responses"] = if node.responses.any?
55
+ node.responses.each_with_object({}) do |(status, response), memo|
56
+ memo[status] = if response.nil?
57
+ { "description" => "" }
58
+ elsif response.key?("$ref") && response["$ref"].start_with?("#/components/responses")
59
+ response
60
+ else
61
+ { "description" => "", "content" => { "application/json" => { "schema" => response } } }
62
+ end
63
+ end
64
+ else
65
+ { "200" => { "description" => "" } }
66
+ end
67
+
68
+ if node.request
69
+ if node.request.key?("$ref") && node.request["$ref"].start_with?("#/components/requestBodies")
70
+ memo[path][method]["requestBody"] = node.request
71
+ else
72
+ memo[path][method]["requestBody"] = { "content" => { "application/json" => { "schema" => node.request } } }
73
+ end
74
+ end
75
+ end
76
+
77
+ if @used_security_schemes.any?
78
+ @spec["components"]["securitySchemes"] = @used_security_schemes.each_with_object({}) do |auth_entry, memo|
79
+ memo[auth_entry[:name]] = auth_entry[:definition]
80
+ end
81
+ end
82
+
83
+ if (shared_components = Rage::OpenAPI.__shared_components["components"])
84
+ shared_components.each do |definition_type, definitions|
85
+ (@spec["components"][definition_type] ||= {}).merge!(definitions)
86
+ end
87
+ end
88
+
89
+ @spec["tags"] = @used_tags.sort.map { |tag| { "name" => tag } }
90
+
91
+ @spec
92
+ end
93
+
94
+ private
95
+
96
+ def build_app_name
97
+ basename = Rage.root.basename.to_s
98
+ basename.capitalize.gsub(/[\s\-_]([a-zA-Z0-9]+)/) { " #{$1.capitalize}" }
99
+ end
100
+
101
+ def build_security(node)
102
+ available_before_actions = node.controller.__before_actions_for(node.action.to_sym)
103
+
104
+ node.auth.filter_map do |auth_entry|
105
+ if available_before_actions.any? { |action_entry| action_entry[:name] == auth_entry[:method].to_sym }
106
+ auth_name = auth_entry[:name].gsub(/[^A-Za-z0-9\-._]/, "")
107
+ @used_security_schemes << auth_entry.merge(name: auth_name)
108
+
109
+ { auth_name => [] }
110
+ end
111
+ end
112
+ end
113
+
114
+ def build_tags(node)
115
+ controller_name = node.controller.name.sub(/Controller$/, "")
116
+ namespace_i = controller_name.rindex("::")
117
+
118
+ if namespace_i
119
+ module_name, class_name = controller_name[0...namespace_i], controller_name[namespace_i + 2..]
120
+ else
121
+ module_name, class_name = "", controller_name
122
+ end
123
+
124
+ tag = if module_name =~ /::(V\d+)/
125
+ "#{$1.downcase}/#{class_name}"
126
+ else
127
+ class_name
128
+ end
129
+
130
+ if (custom_tag_resolver = Rage.config.openapi.tag_resolver)
131
+ tag = custom_tag_resolver.call(node.controller, node.action.to_sym, tag)
132
+ end
133
+
134
+ Array(tag).tap do |node_tags|
135
+ @used_tags += node_tags
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="description" content="SwaggerUI" />
7
+ <title>SwaggerUI</title>
8
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@^5/swagger-ui.css" />
9
+ </head>
10
+ <body>
11
+ <div id="swagger-ui"></div>
12
+ <script src="https://unpkg.com/swagger-ui-dist@^5/swagger-ui-bundle.js" crossorigin></script>
13
+ <script>
14
+ window.onload = () => {
15
+ window.ui = SwaggerUIBundle({
16
+ url: '<%= spec_url %>',
17
+ dom_id: '#swagger-ui',
18
+ });
19
+ };
20
+ </script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,24 @@
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
+ def initialize(controller, action, parents)
9
+ @controller = controller
10
+ @action = action
11
+ @parents = parents
12
+
13
+ @responses = {}
14
+ @parameters = []
15
+ end
16
+
17
+ def root
18
+ @parents[0].root
19
+ end
20
+
21
+ def auth
22
+ @parents.flat_map(&:auth)
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Rage::OpenAPI::Nodes::Parent
4
+ attr_reader :root, :controller
5
+ attr_accessor :deprecated, :private, :auth
6
+
7
+ def initialize(root, controller)
8
+ @root = root
9
+ @controller = controller
10
+
11
+ @auth = []
12
+ end
13
+ end
@@ -0,0 +1,49 @@
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<ApplicationController>
15
+ # |
16
+ # Nodes::Parent<Api::BaseController>
17
+ # / \
18
+ # Nodes::Parent<Api::V1::UsersController> Nodes::Parent<Api::V2::UsersController>
19
+ # / \ |
20
+ # Nodes::Method<index> Nodes::Method<show> Nodes::Method<show>
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
+ def parent_nodes
32
+ @parent_nodes_cache.values
33
+ end
34
+
35
+ def new_method_node(controller, action, parent_nodes)
36
+ node = Rage::OpenAPI::Nodes::Method.new(controller, action, parent_nodes)
37
+ @leaves << node
38
+
39
+ node
40
+ end
41
+
42
+ def new_parent_node(controller)
43
+ @parent_nodes_cache[controller] ||= begin
44
+ node = Rage::OpenAPI::Nodes::Parent.new(self, controller)
45
+ yield(node)
46
+ node
47
+ end
48
+ end
49
+ end