rails-openapi 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6012b8d66ea54b85a1856f9072bdefd89714d92a2b101798d86f9c19a4a15961
4
+ data.tar.gz: 0c2206e467404c49f60c7e022058fc7cf8751537339a80f4458d32ad29bbe75d
5
+ SHA512:
6
+ metadata.gz: 0ef32988bf178e4f9e37e798bc86e4ff1ccc129b41e104017f93ffcd185ee3c8ec7684430e3ffedc3c79eac07f85614662ec815cc7f118dcd9a522dd2c1e30cd
7
+ data.tar.gz: '087a18d21c99dba35f372f344b3226c536d656b8c4d33971761018b8e0db187574adad1c9904357ff8f68c619ae1cbf7ef4d2bdafd6e96d88ec37ed6d4546fe7'
@@ -0,0 +1,143 @@
1
+ module Rails
2
+ module Openapi
3
+ # Defines a base class from which OpenAPI engines can be created.
4
+ # Uses namespace isolation to ensure routes don't conflict with any
5
+ # pre-existing routes from the main rails application.
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Rails::Openapi
8
+ end
9
+
10
+ # Helper method to create a new engine based on a module namespace prefix and an OpenAPI spec file
11
+ def self.Engine namespace:, schema:, publish_schema: true
12
+ # Convert the module prefix into a constant if passed in as a string
13
+ base_module = Object.const_get namespace if String === namespace
14
+
15
+ # Ensure the OpenAPI spec file is in an acceptable format
16
+ begin
17
+ require "yaml"
18
+ document = YAML.safe_load schema
19
+ unless document.is_a?(Hash) && document["openapi"].present?
20
+ raise "The schema argument could not be parsed as an OpenAPI schema"
21
+ end
22
+ unless Gem::Version.new(document["openapi"]) >= Gem::Version.new("3.0")
23
+ raise "The schema argument must be an OpenAPI 3.0+ schema. You passed in a schema with version #{document["openapi"]}"
24
+ end
25
+ rescue Psych::SyntaxError
26
+ raise $!, "Problem parsing OpenAPI schema: #{$!.message.lines.first.strip}", $@
27
+ end
28
+
29
+ # Builds a routing tree based on the OpenAPI spec file.
30
+ # We'll add each endpoint to the routing tree and additionally
31
+ # store it in an array to be used below.
32
+ router = Router.new
33
+ endpoints = []
34
+ document["paths"].each do |url, actions|
35
+ actions.each do |verb, definition|
36
+ next if verb == "parameters"
37
+ route = Endpoint.new(verb.downcase.to_sym, url, definition)
38
+ router << route
39
+ endpoints << route
40
+ end
41
+ end
42
+
43
+ # Creates the engine that will be used to actually route the
44
+ # contents of the OpenAPI spec file. The engine will eventually be
45
+ # attached to the base module (argument to this current method).
46
+ #
47
+ # Exposes `::router` and `::endpoints` methods to allow other parts
48
+ # of the code to tie requests back to their spec file definitions.
49
+ engine = Class.new Engine do
50
+ @router = router
51
+ @endpoints = {}
52
+ @schema = document.freeze
53
+
54
+ class << self
55
+ attr_reader :router
56
+
57
+ attr_reader :endpoints
58
+
59
+ attr_reader :schema
60
+ end
61
+
62
+ # Rack app for serving the original OpenAPI file
63
+ openapi_app = Class.new do
64
+ def inspect
65
+ "Rails::Openapi::Engine"
66
+ end
67
+ define_method :call do |env|
68
+ [
69
+ 200,
70
+ {"Content-Type" => "application/json"},
71
+ [engine.schema.to_json]
72
+ ]
73
+ end
74
+ end
75
+
76
+ # Adds routes to the engine by passing the Mapper to the top
77
+ # of the routing tree. `self` inside the block refers to an
78
+ # instance of `ActionDispatch::Routing::Mapper`.
79
+ routes.draw do
80
+ scope module: base_module.name.underscore, format: false do
81
+ get "openapi.json", to: openapi_app.new, as: :openapi_schema if publish_schema
82
+ router.draw self
83
+ end
84
+ end
85
+ end
86
+
87
+ # Assign the engine as a class on the base module
88
+ base_module.const_set :Engine, engine
89
+
90
+ # Creates a hash that maps routes back to their OpenAPI spec file
91
+ # equivalents. This is accomplished by mocking a request for each
92
+ # OpenAPI spec file endpoint and determining which controller and
93
+ # action the request is routed to. OpenAPI spec file definitions
94
+ # are then attached to that controller/action pair.
95
+ endpoints.each do |route|
96
+ # Mocks a request using the route's URL
97
+ url = ::ActionDispatch::Journey::Router::Utils.normalize_path route.path
98
+ env = ::Rack::MockRequest.env_for url, method: route[:method].upcase
99
+ req = ::ActionDispatch::Request.new env
100
+
101
+ # Maps the OpenAPI spec endpoint to the destination controller
102
+ # action by routing the request.
103
+ begin
104
+ mapped = engine.routes.router.recognize(req) {}.first[2].defaults
105
+ rescue
106
+ Rails.logger.error "Could not resolve the OpenAPI route for #{req.method} #{req.url}"
107
+ next
108
+ end
109
+ key = "#{mapped[:controller]}##{mapped[:action]}"
110
+ engine.endpoints[key] = route
111
+ end
112
+ engine.endpoints.freeze
113
+
114
+ # Defines a helper module on the base module that can be used to
115
+ # properly generate OpenAPI-aware controllers. Any controllers
116
+ # referenced from a OpenAPI spec file should include this module.
117
+ mod = Module.new do
118
+ @base = base_module
119
+ def self.included controller
120
+ base_module = @base
121
+ # Returns a reference to the Rails engine generated for this OpenAPI spec file
122
+ define_method :openapi_engine do
123
+ base_module.const_get :Engine
124
+ end
125
+ # Returns the OpenAPI schema used to generate the Rails engine as a Hash
126
+ define_method :openapi_schema do
127
+ base_module.const_get(:Engine).schema
128
+ end
129
+ # Returns the OpenAPI spec's endpoint definition for current request
130
+ define_method :openapi_endpoint do
131
+ openapi_engine.endpoints["#{request.path_parameters[:controller]}##{request.path_parameters[:action]}"]
132
+ end
133
+ # Allows the helper methods to also be used in views
134
+ controller.helper_method :openapi_engine, :openapi_schema, :openapi_endpoint
135
+ end
136
+ end
137
+ base_module.const_set :OpenapiHelper, mod
138
+
139
+ # Returns the new engine
140
+ base_module.const_get :Engine
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,214 @@
1
+ module Rails
2
+ module Openapi
3
+ # Internally represents individual routes
4
+ Endpoint = Struct.new(:method, :url, :definition, :_path) do
5
+ def initialize *opts
6
+ super
7
+ self[:_path] = path
8
+ end
9
+
10
+ # Translates path params from {bracket} syntax to :symbol syntax
11
+ def path
12
+ self[:url].gsub(/\{([^}]+)\}/, ':\\1')
13
+ end
14
+ end
15
+
16
+ # Defines RESTful routing conventions
17
+ RESOURCE_ROUTES = {
18
+ get: :index,
19
+ post: :create,
20
+ put: :update,
21
+ patch: :update,
22
+ delete: :destroy
23
+ }.freeze
24
+ PARAM_ROUTES = {
25
+ get: :show,
26
+ post: :update,
27
+ patch: :update,
28
+ put: :update,
29
+ delete: :destroy
30
+ }.freeze
31
+
32
+ class Router
33
+ attr_accessor :endpoints
34
+
35
+ def initialize prefix = [], parent = nil
36
+ @parent = parent
37
+ @prefix = prefix.freeze
38
+ @endpoints = []
39
+ @subroutes = Hash.new do |hash, k|
40
+ hash[k] = Router.new(@prefix + [k], self)
41
+ end
42
+ end
43
+
44
+ # Adds an individual endpoint to the routing tree
45
+ def << route
46
+ raise "Argument must be an Endpoint" unless Endpoint === route
47
+ _base, *subroute = route[:_path].split "/" # Split out first element
48
+ if subroute.count == 0
49
+ route[:_path] = ""
50
+ @endpoints << route
51
+ else
52
+ route[:_path] = subroute.join "/"
53
+ self[subroute[0]] << route
54
+ end
55
+ end
56
+
57
+ # Returns a specific branch of the routing tree
58
+ def [] path
59
+ @subroutes[path]
60
+ end
61
+
62
+ # Returns the routing path
63
+ def path
64
+ "/" + @prefix.join("/")
65
+ end
66
+
67
+ # Returns the mode used for collecting routes
68
+ def route_mode
69
+ mode = :resource
70
+ mode = :namespace if @endpoints.count == 0
71
+ mode = :action if @subroutes.count == 0 && @parent && @parent.route_mode == :resource
72
+ mode = :param if /^:/.match?(@prefix.last)
73
+ mode
74
+ end
75
+
76
+ # Returns the mode used for actions in this router
77
+ def action_mode
78
+ if /^:/.match?(@prefix[-1])
79
+ :member
80
+ else
81
+ :collection
82
+ end
83
+ end
84
+
85
+ # Determines the action for a specific route
86
+ def action_for route
87
+ raise "Argument must be an Endpoint" unless Endpoint === route
88
+ action = @prefix[-1]&.underscore || ""
89
+ action = PARAM_ROUTES[route[:method]] if action_mode == :member
90
+ action = RESOURCE_ROUTES[route[:method]] if route_mode == :resource && action_mode == :collection
91
+ action
92
+ end
93
+
94
+ # Draws the routes for this router
95
+ def draw map
96
+ case route_mode
97
+ when :resource
98
+
99
+ # Find collection-level resource actions
100
+ actions = @endpoints.map { |r| action_for r }.select { |a| Symbol === a }
101
+
102
+ # Find parameter-level resource actions
103
+ @subroutes.select { |k, _| /^:/ === k }.values.each do |subroute|
104
+ actions += subroute.endpoints.map { |r| subroute.action_for r }.select { |a| Symbol === a }
105
+ end
106
+
107
+ # Determine if this is a collection or a singleton resource
108
+ type = @subroutes.any? { |k, _| /^:/ === k } ? :resources : :resource
109
+ if type == :resource && actions.include?(:index)
110
+ # Remap index to show for singleton resources
111
+ actions.delete :index
112
+ actions << :show
113
+ end
114
+
115
+ # Draw the resource
116
+ map.send type, @prefix.last&.to_sym || "/", controller: @prefix.last&.underscore || "main", only: actions, as: @prefix.last&.underscore || "main", format: nil do
117
+ # Draw custom actions
118
+ draw_actions! map
119
+
120
+ # Handle the edge case in which POST is used instead of PUT/PATCH
121
+ draw_post_updates! map
122
+
123
+ # Draw a namespace (unless at the top)
124
+ if @prefix.join("/").blank?
125
+ draw_subroutes! map
126
+ else
127
+ map.scope module: @prefix.last do
128
+ draw_subroutes! map
129
+ end
130
+ end
131
+ end
132
+
133
+ when :namespace
134
+
135
+ # Draw a namespace (unless at the top)
136
+ if @prefix.join("/").blank?
137
+ draw_subroutes! map
138
+ else
139
+ map.namespace @prefix.last do
140
+ draw_subroutes! map
141
+ end
142
+ end
143
+
144
+ when :param
145
+
146
+ # Draw subroutes directly
147
+ draw_subroutes! map
148
+ draw_actions! map
149
+
150
+ when :action
151
+ # Draw actions directly
152
+ draw_actions! map
153
+
154
+ end
155
+ end
156
+
157
+ # Returns the routing tree in text format
158
+ def to_s
159
+ output = ""
160
+
161
+ path = "/" + @prefix.join("/")
162
+ @endpoints.each do |route|
163
+ output += "#{route[:method].to_s.upcase} #{path}\n"
164
+ end
165
+ @subroutes.each do |k, subroute|
166
+ output += subroute.to_s
167
+ end
168
+
169
+ output
170
+ end
171
+
172
+ # Outputs a visual representation of the routing tree
173
+ def _debug_routing_tree
174
+ puts path + " - #{route_mode}"
175
+ @endpoints.each do |route|
176
+ puts "\t#{route[:method].to_s.upcase} to ##{action_for route} (#{action_mode})"
177
+ end
178
+ @subroutes.each { |k, subroute| subroute._debug_routing_tree }
179
+ end
180
+
181
+ protected
182
+
183
+ # Some APIs use the POST verb instead of PUT/PATCH for updating resources
184
+ def draw_post_updates! map
185
+ @subroutes.select { |k, _| /^:/ === k }.each do |param, subroute|
186
+ if subroute.endpoints.select { |r| r[:method] == :post }.any?
187
+ map.match param, via: :post, action: :update, on: :collection
188
+ end
189
+ end
190
+ end
191
+
192
+ def draw_actions! map
193
+ @endpoints.each do |route|
194
+ # Params hash for the route to be added
195
+ params = {}
196
+ params[:via] = route[:method]
197
+ params[:action] = action_for route
198
+ params[:on] = action_mode unless action_mode == :member
199
+ params[:as] = @prefix.last&.underscore
200
+
201
+ # Skip actions that are handled in the resource level
202
+ next if Symbol === params[:action]
203
+
204
+ # Add this individual route
205
+ map.match @prefix.last, params
206
+ end
207
+ end
208
+
209
+ def draw_subroutes! map
210
+ @subroutes.values.each { |r| r.draw map }
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Openapi
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require_relative "openapi/version"
5
+ require_relative "openapi/engine"
6
+ require_relative "openapi/router"
@@ -0,0 +1,6 @@
1
+ module Rails
2
+ module Openapi
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-openapi
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Kenaniah Cerny
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-07-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: colorize
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: debug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 1.0.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 1.0.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.3'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.3'
111
+ description:
112
+ email:
113
+ - kenaniah@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - lib/rails/openapi.rb
119
+ - lib/rails/openapi/engine.rb
120
+ - lib/rails/openapi/router.rb
121
+ - lib/rails/openapi/version.rb
122
+ - sig/rails/openapi.rbs
123
+ homepage: https://github.com/kenaniah/rails-openapi
124
+ licenses:
125
+ - MIT
126
+ metadata:
127
+ homepage_uri: https://github.com/kenaniah/rails-openapi
128
+ source_code_uri: https://github.com/kenaniah/rails-openapi
129
+ changelog_uri: https://github.com/kenaniah/rails-openapi/blob/main/CHANGELOG.md
130
+ post_install_message:
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 2.6.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 3.2.33
146
+ signing_key:
147
+ specification_version: 4
148
+ summary: Creates rails engines from OpenAPI specification files
149
+ test_files: []