rails-openapi 1.0.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 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: []