rage-rb 1.10.1 → 1.11.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87a1aecc1f62917581dee82c9efe0d56cebc69046500eeaf7f2e73aa7f35f809
4
- data.tar.gz: 6e890b8641f214b2cfbebc2fd56bc1f5a32f2f8c2281cbf8f62d3a607d05c4ad
3
+ metadata.gz: 04ca7e51d4117d534058db889d82d6ce86af9c9edf389a03711baffa3a8bc484
4
+ data.tar.gz: 347fa6296d6fa65d99125146b7f82921a9ae2b99836d6f0191fc31dc135896c7
5
5
  SHA512:
6
- metadata.gz: c659a925cd991c383714d48e803a93edec1703c5e99aacd73d7c4748be00e77ec8077f6c86ec31c13c8fab373c9ef22bc11fec647b0bc233abec710967a827f4
7
- data.tar.gz: a0085405ad42e00730128e942b72abf9944f9344d2e9d5b178b393f8f019b517b430a0b3bf3ac7dd8b8e575392e4f79f7c0cc8531093be3d8100a549f13583f2
6
+ metadata.gz: 8490a009689d8dbd9c98f47b01701e80dda65fe96dfa991540adc59dc0dd68859690263a15a6cb3f2f2ad23aa300c8bcf4eb6aed468a69b9997191af15806bae
7
+ data.tar.gz: 7b5d887f78d0d102a9ea92027fe531d61870bed70828a56a4b79c51540c560553dd907c7c27a72867b4adbc241e4e1a4a529b84db991786d6845b457c229bfb2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## [1.10.1] - 2024-09-17
4
14
 
5
15
  ### Fixed
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
@@ -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