insights-api-common 3.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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +202 -0
  3. data/README.md +102 -0
  4. data/Rakefile +18 -0
  5. data/app/models/authentication.rb +19 -0
  6. data/app/models/concerns/encryption_concern.rb +52 -0
  7. data/app/models/encryption.rb +13 -0
  8. data/lib/generators/shared_utilities/migration_generator.rb +79 -0
  9. data/lib/generators/shared_utilities/orm_helper.rb +25 -0
  10. data/lib/generators/shared_utilities/templates/migration.rb +27 -0
  11. data/lib/generators/shared_utilities/templates/migration_existing.rb +28 -0
  12. data/lib/insights.rb +1 -0
  13. data/lib/insights/api/common.rb +12 -0
  14. data/lib/insights/api/common/application_controller_mixins/api_doc.rb +39 -0
  15. data/lib/insights/api/common/application_controller_mixins/common.rb +27 -0
  16. data/lib/insights/api/common/application_controller_mixins/exception_handling.rb +41 -0
  17. data/lib/insights/api/common/application_controller_mixins/openapi_enabled.rb +13 -0
  18. data/lib/insights/api/common/application_controller_mixins/parameters.rb +134 -0
  19. data/lib/insights/api/common/application_controller_mixins/request_body_validation.rb +48 -0
  20. data/lib/insights/api/common/application_controller_mixins/request_parameter_validation.rb +29 -0
  21. data/lib/insights/api/common/application_controller_mixins/request_path.rb +70 -0
  22. data/lib/insights/api/common/engine.rb +20 -0
  23. data/lib/insights/api/common/entitlement.rb +37 -0
  24. data/lib/insights/api/common/error_document.rb +29 -0
  25. data/lib/insights/api/common/filter.rb +175 -0
  26. data/lib/insights/api/common/graphql.rb +127 -0
  27. data/lib/insights/api/common/graphql/associated_records.rb +44 -0
  28. data/lib/insights/api/common/graphql/association_loader.rb +35 -0
  29. data/lib/insights/api/common/graphql/generator.rb +148 -0
  30. data/lib/insights/api/common/graphql/templates/model_type.erb +35 -0
  31. data/lib/insights/api/common/graphql/templates/query_type.erb +49 -0
  32. data/lib/insights/api/common/graphql/templates/schema.erb +6 -0
  33. data/lib/insights/api/common/graphql/types/big_int.rb +23 -0
  34. data/lib/insights/api/common/graphql/types/date_time.rb +16 -0
  35. data/lib/insights/api/common/graphql/types/query_filter.rb +16 -0
  36. data/lib/insights/api/common/graphql/types/query_sort_by.rb +16 -0
  37. data/lib/insights/api/common/inflections.rb +28 -0
  38. data/lib/insights/api/common/logging.rb +17 -0
  39. data/lib/insights/api/common/metrics.rb +39 -0
  40. data/lib/insights/api/common/middleware/web_server_metrics.rb +62 -0
  41. data/lib/insights/api/common/open_api.rb +2 -0
  42. data/lib/insights/api/common/open_api/docs.rb +54 -0
  43. data/lib/insights/api/common/open_api/docs/component_collection.rb +67 -0
  44. data/lib/insights/api/common/open_api/docs/doc_v3.rb +102 -0
  45. data/lib/insights/api/common/open_api/docs/object_definition.rb +39 -0
  46. data/lib/insights/api/common/open_api/generator.rb +520 -0
  47. data/lib/insights/api/common/open_api/serializer.rb +31 -0
  48. data/lib/insights/api/common/option_redirect_enhancements.rb +23 -0
  49. data/lib/insights/api/common/paginated_response.rb +108 -0
  50. data/lib/insights/api/common/rbac/access.rb +66 -0
  51. data/lib/insights/api/common/rbac/acl.rb +74 -0
  52. data/lib/insights/api/common/rbac/policies.rb +33 -0
  53. data/lib/insights/api/common/rbac/query_shared_resource.rb +45 -0
  54. data/lib/insights/api/common/rbac/roles.rb +77 -0
  55. data/lib/insights/api/common/rbac/seed.rb +140 -0
  56. data/lib/insights/api/common/rbac/service.rb +67 -0
  57. data/lib/insights/api/common/rbac/share_resource.rb +60 -0
  58. data/lib/insights/api/common/rbac/unshare_resource.rb +32 -0
  59. data/lib/insights/api/common/rbac/utilities.rb +30 -0
  60. data/lib/insights/api/common/request.rb +111 -0
  61. data/lib/insights/api/common/routing.rb +26 -0
  62. data/lib/insights/api/common/user.rb +48 -0
  63. data/lib/insights/api/common/version.rb +7 -0
  64. data/lib/tasks/insights/api/common_tasks.rake +4 -0
  65. data/spec/support/default_as_json.rb +17 -0
  66. data/spec/support/rbac_shared_contexts.rb +44 -0
  67. data/spec/support/requests_spec_helper.rb +7 -0
  68. data/spec/support/service_spec_helper.rb +26 -0
  69. data/spec/support/user_header_spec_helper.rb +68 -0
  70. metadata +403 -0
@@ -0,0 +1,2 @@
1
+ require "insights/api/common/open_api/docs"
2
+ require "insights/api/common/open_api/serializer"
@@ -0,0 +1,54 @@
1
+ require "insights/api/common/open_api/docs/component_collection"
2
+ require "insights/api/common/open_api/docs/object_definition"
3
+ require "insights/api/common/open_api/docs/doc_v3"
4
+
5
+ module Insights
6
+ module API
7
+ module Common
8
+ module OpenApi
9
+ class Docs
10
+ def self.instance
11
+ @instance ||= new(Dir.glob(Rails.root.join("public", "doc", "openapi*.json")))
12
+ end
13
+
14
+ def initialize(glob)
15
+ @cache = {}
16
+ glob.each { |f| load_file(f) }
17
+ end
18
+
19
+ def load_file(file)
20
+ openapi_spec = JSON.parse(File.read(file))
21
+ store_doc(DocV3.new(openapi_spec))
22
+ end
23
+
24
+ def store_doc(doc)
25
+ update_doc_for_version(doc, doc.version.segments[0..1].join("."))
26
+ update_doc_for_version(doc, doc.version.segments.first.to_s)
27
+ end
28
+
29
+ def update_doc_for_version(doc, version)
30
+ if @cache[version].nil?
31
+ @cache[version] = doc
32
+ else
33
+ existing_version = @cache[version].version
34
+ @cache[version] = doc if doc.version > existing_version
35
+ end
36
+ end
37
+
38
+ def [](version)
39
+ @cache[version]
40
+ end
41
+
42
+ def routes
43
+ @routes ||= begin
44
+ @cache.each_with_object([]) do |(version, doc), routes|
45
+ next unless /\d+\.\d+/ =~ version # Skip unless major.minor
46
+ routes.concat(doc.routes)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,67 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module OpenApi
5
+ class Docs
6
+ class ComponentCollection < Hash
7
+ attr_reader :doc
8
+
9
+ def initialize(doc, category)
10
+ @doc = doc
11
+ @category = category
12
+ end
13
+
14
+ def [](name)
15
+ super || load_definition(name)
16
+ end
17
+
18
+ def load_definition(name)
19
+ raw_definition = @doc.content.fetch_path(*@category.split("/"), name)
20
+ raise ArgumentError, "Failed to find definition for #{name}" unless raw_definition.kind_of?(Hash)
21
+
22
+ definition = substitute_regexes(raw_definition)
23
+ definition = substitute_references(definition)
24
+ self[name] = ::Insights::API::Common::OpenApi::Docs::ObjectDefinition.new.replace(definition)
25
+ end
26
+
27
+ private
28
+
29
+ def substitute_references(object)
30
+ if object.kind_of?(Array)
31
+ object.collect { |i| substitute_references(i) }
32
+ elsif object.kind_of?(Hash)
33
+ return fetch_ref_value(object["$ref"]) if object.keys == ["$ref"]
34
+ object.each { |k, v| object[k] = substitute_references(v) }
35
+ else
36
+ object
37
+ end
38
+ end
39
+
40
+ def fetch_ref_value(ref_path)
41
+ ref_paths = ref_path.split("/")
42
+ property = ref_paths.last
43
+ section = ref_paths[1..-2]
44
+ public_send(:[], property)
45
+ end
46
+
47
+ def substitute_regexes(object)
48
+ if object.kind_of?(Array)
49
+ object.collect { |i| substitute_regexes(i) }
50
+ elsif object.kind_of?(Hash)
51
+ object.each_with_object({}) do |(k, v), o|
52
+ o[k] = k == "pattern" ? regexp_from_pattern(v) : substitute_regexes(v)
53
+ end
54
+ else
55
+ object
56
+ end
57
+ end
58
+
59
+ def regexp_from_pattern(pattern)
60
+ Regexp.new(pattern)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,102 @@
1
+ require "more_core_extensions/core_ext/hash"
2
+ require "more_core_extensions/core_ext/array"
3
+ require "openapi_parser"
4
+
5
+ module Insights
6
+ module API
7
+ module Common
8
+ module OpenApi
9
+ class Docs
10
+ class DocV3
11
+ attr_reader :content
12
+
13
+ def initialize(content)
14
+ spec_version = content["openapi"]
15
+ raise "Unsupported OpenAPI Specification version #{spec_version}" unless spec_version =~ /\A3\..*\z/
16
+
17
+ @content = content
18
+ end
19
+
20
+ # Validates data types against OpenAPI schema
21
+ #
22
+ # @param http_method [String] POST/PATCH/...
23
+ # @param request_path [String] i.e. /api/sources/v1.0/sources
24
+ # @param api_version [String] i.e. "v1.0", has to be part of **request_path**
25
+ # @param payload [String] JSON if payload_content_type == 'application/json'
26
+ # @param payload_content_type [String]
27
+ #
28
+ # @raise OpenAPIParser::OpenAPIError
29
+ def validate!(http_method, request_path, api_version, payload, payload_content_type = 'application/json')
30
+ path = request_path.split(api_version)[1]
31
+ raise "API version not found in request_path" if path.nil?
32
+
33
+ request_operation = validator_doc.request_operation(http_method.to_s.downcase, path)
34
+ request_operation.validate_request_body(payload_content_type, payload)
35
+ end
36
+
37
+ def validate_parameters!(http_method, request_path, api_version, params)
38
+ path = request_path.split(api_version)[1]
39
+ raise "API version not found in request_path" if path.nil?
40
+
41
+ request_operation = validator_doc.request_operation(http_method.to_s.downcase, path)
42
+ return unless request_operation
43
+
44
+ request_operation.validate_request_parameter(params, {})
45
+ end
46
+
47
+ def version
48
+ @version ||= Gem::Version.new(content.fetch_path("info", "version"))
49
+ end
50
+
51
+ def parameters
52
+ @parameters ||= ::Insights::API::Common::OpenApi::Docs::ComponentCollection.new(self, "components/parameters")
53
+ end
54
+
55
+ def schemas
56
+ @schemas ||= ::Insights::API::Common::OpenApi::Docs::ComponentCollection.new(self, "components/schemas")
57
+ end
58
+
59
+ def definitions
60
+ schemas
61
+ end
62
+
63
+ def example_attributes(key)
64
+ schemas[key]["properties"].each_with_object({}) do |(col, stuff), hash|
65
+ hash[col] = stuff["example"] if stuff.key?("example")
66
+ end
67
+ end
68
+
69
+ def base_path
70
+ @base_path ||= @content.fetch_path("servers", 0, "variables", "basePath", "default")
71
+ end
72
+
73
+ def paths
74
+ @content["paths"]
75
+ end
76
+
77
+ def to_json(options = nil)
78
+ content.to_json(options)
79
+ end
80
+
81
+ def routes
82
+ @routes ||= begin
83
+ paths.flat_map do |path, hash|
84
+ hash.collect do |verb, _details|
85
+ p = File.join(base_path, path).gsub(/{\w*}/, ":id")
86
+ {:path => p, :verb => verb.upcase}
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def validator_doc(opts = { :coerce_value => true, :datetime_coerce_class => DateTime })
95
+ @validator_doc ||= ::OpenAPIParser.parse(content, opts)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,39 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module OpenApi
5
+ class Docs
6
+ class ObjectDefinition < Hash
7
+ def all_attributes
8
+ properties.map { |key, val| all_attributes_recursive(key, val) }
9
+ end
10
+
11
+ def read_only_attributes
12
+ properties.select { |k, v| v["readOnly"] == true }.keys
13
+ end
14
+
15
+ def required_attributes
16
+ self["required"]
17
+ end
18
+
19
+ def properties
20
+ self["properties"]
21
+ end
22
+
23
+ private
24
+
25
+ def all_attributes_recursive(key, value)
26
+ if value["properties"]
27
+ {
28
+ key => value["properties"].map { |k, v| all_attributes_recursive(k, v) }
29
+ }
30
+ else
31
+ key
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,520 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module OpenApi
5
+ class Generator
6
+ require 'json'
7
+ require 'insights/api/common/graphql'
8
+
9
+ PARAMETERS_PATH = "/components/parameters".freeze
10
+ SCHEMAS_PATH = "/components/schemas".freeze
11
+
12
+ def path_parts(openapi_path)
13
+ openapi_path.split("/")[1..-1]
14
+ end
15
+
16
+ # Let's get the latest api version based on the openapi.json routes
17
+ def api_version
18
+ @api_version ||= Rails.application.routes.routes.each_with_object([]) do |route, array|
19
+ matches = ActionDispatch::Routing::RouteWrapper
20
+ .new(route)
21
+ .path.match(/\A.*\/v(\d+.\d+)\/openapi.json.*\z/)
22
+ array << matches[1] if matches
23
+ end.max
24
+ end
25
+
26
+ def rails_routes
27
+ Rails.application.routes.routes.each_with_object([]) do |route, array|
28
+ r = ActionDispatch::Routing::RouteWrapper.new(route)
29
+ next if r.internal? # Don't display rails routes
30
+ next if r.engine? # Don't care right now...
31
+
32
+ array << r
33
+ end
34
+ end
35
+
36
+ def openapi_file
37
+ @openapi_file ||= Rails.root.join("public", "doc", "openapi-3-v#{api_version}.0.json").to_s
38
+ end
39
+
40
+ def openapi_contents
41
+ @openapi_contents ||= begin
42
+ JSON.parse(File.read(openapi_file))
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ app_prefix, app_name = base_path.match(/\A(.*)\/(.*)\/v\d+.\d+\z/).captures
48
+ ENV['APP_NAME'] = app_name
49
+ ENV['PATH_PREFIX'] = app_prefix
50
+ Rails.application.reload_routes!
51
+ end
52
+
53
+ def base_path
54
+ openapi_contents["servers"].first["variables"]["basePath"]["default"]
55
+ end
56
+
57
+ def applicable_rails_routes
58
+ rails_routes.select { |i| i.path.start_with?(base_path) }
59
+ end
60
+
61
+ def schemas
62
+ @schemas ||= {
63
+ "CollectionLinks" => {
64
+ "type" => "object",
65
+ "properties" => {
66
+ "first" => {
67
+ "type" => "string"
68
+ },
69
+ "last" => {
70
+ "type" => "string"
71
+ },
72
+ "next" => {
73
+ "type" => "string"
74
+ },
75
+ "prev" => {
76
+ "type" => "string"
77
+ },
78
+ }
79
+ },
80
+ "CollectionMetadata" => {
81
+ "type" => "object",
82
+ "properties" => {
83
+ "count" => {
84
+ "type" => "integer"
85
+ },
86
+ "limit" => {
87
+ "type" => "integer"
88
+ },
89
+ "offset" => {
90
+ "type" => "integer"
91
+ }
92
+ }
93
+ },
94
+ "ID" => {
95
+ "type" => "string",
96
+ "description" => "ID of the resource",
97
+ "pattern" => "^\\d+$",
98
+ "readOnly" => true,
99
+ },
100
+ "SortByAttribute" => {
101
+ "type" => "string",
102
+ "description" => "Attribute with optional order to sort the result set by.",
103
+ "pattern" => "^[a-z\\-_]+(:asc|:desc)?$"
104
+ }
105
+ }
106
+ end
107
+
108
+ def build_schema(klass_name)
109
+ schemas[klass_name] = openapi_schema(klass_name)
110
+ "##{SCHEMAS_PATH}/#{klass_name}"
111
+ end
112
+
113
+ def build_schema_error_not_found
114
+ klass_name = "ErrorNotFound"
115
+
116
+ schemas[klass_name] = {
117
+ "type" => "object",
118
+ "properties" => {
119
+ "errors" => {
120
+ "type" => "array",
121
+ "items" => {
122
+ "type" => "object",
123
+ "properties" => {
124
+ "status" => {
125
+ "type" => "integer",
126
+ "example" => 404
127
+ },
128
+ "detail" => {
129
+ "type" => "string",
130
+ "example" => "Record not found"
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ "##{SCHEMAS_PATH}/#{klass_name}"
139
+ end
140
+
141
+ def parameters
142
+ @parameters ||= {
143
+ "QueryFilter" => {
144
+ "in" => "query",
145
+ "name" => "filter",
146
+ "description" => "Filter for querying collections.",
147
+ "required" => false,
148
+ "style" => "deepObject",
149
+ "explode" => true,
150
+ "schema" => {
151
+ "type" => "object"
152
+ }
153
+ },
154
+ "QueryLimit" => {
155
+ "in" => "query",
156
+ "name" => "limit",
157
+ "description" => "The numbers of items to return per page.",
158
+ "required" => false,
159
+ "schema" => {
160
+ "type" => "integer",
161
+ "minimum" => 1,
162
+ "maximum" => 1000,
163
+ "default" => 100
164
+ }
165
+ },
166
+ "QueryOffset" => {
167
+ "in" => "query",
168
+ "name" => "offset",
169
+ "description" => "The number of items to skip before starting to collect the result set.",
170
+ "required" => false,
171
+ "schema" => {
172
+ "type" => "integer",
173
+ "minimum" => 0,
174
+ "default" => 0
175
+ }
176
+ },
177
+ "QuerySortBy" => {
178
+ "in" => "query",
179
+ "name" => "sort_by",
180
+ "description" => "The list of attribute and order to sort the result set by.",
181
+ "required" => false,
182
+ "schema" => {
183
+ "oneOf" => [
184
+ { "$ref" => "##{SCHEMAS_PATH}/SortByAttribute" },
185
+ { "type" => "array", "items" => { "$ref" => "##{SCHEMAS_PATH}/SortByAttribute" } }
186
+ ]
187
+ }
188
+ }
189
+ }
190
+ end
191
+
192
+ def build_parameter(name, value = nil)
193
+ parameters[name] = value
194
+ "##{PARAMETERS_PATH}/#{name}"
195
+ end
196
+
197
+ def openapi_schema(klass_name)
198
+ {
199
+ "type" => "object",
200
+ "properties" => openapi_schema_properties(klass_name),
201
+ "additionalProperties" => false
202
+ }
203
+ end
204
+
205
+ def openapi_list_description(klass_name, primary_collection)
206
+ sub_collection = (primary_collection != klass_name)
207
+ {
208
+ "summary" => "List #{klass_name.pluralize}#{" for #{primary_collection}" if sub_collection}",
209
+ "operationId" => "list#{primary_collection if sub_collection}#{klass_name.pluralize}",
210
+ "description" => "Returns an array of #{klass_name} objects",
211
+ "parameters" => [
212
+ { "$ref" => "##{PARAMETERS_PATH}/QueryLimit" },
213
+ { "$ref" => "##{PARAMETERS_PATH}/QueryOffset" },
214
+ { "$ref" => "##{PARAMETERS_PATH}/QueryFilter" },
215
+ { "$ref" => "##{PARAMETERS_PATH}/QuerySortBy" }
216
+ ],
217
+ "responses" => {
218
+ "200" => {
219
+ "description" => "#{klass_name.pluralize} collection",
220
+ "content" => {
221
+ "application/json" => {
222
+ "schema" => { "$ref" => build_collection_schema(klass_name) }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }.tap do |h|
228
+ h["parameters"] << { "$ref" => build_parameter("ID") } if sub_collection
229
+
230
+ next unless sub_collection
231
+
232
+ h["responses"]["404"] = {
233
+ "description" => "Not found",
234
+ "content" => {
235
+ "application/json" => {
236
+ "schema" => { "$ref" => build_schema_error_not_found }
237
+ }
238
+ }
239
+ }
240
+ end
241
+ end
242
+
243
+ def build_collection_schema(klass_name)
244
+ collection_name = "#{klass_name.pluralize}Collection"
245
+ schemas[collection_name] = {
246
+ "type" => "object",
247
+ "properties" => {
248
+ "meta" => { "$ref" => "##{SCHEMAS_PATH}/CollectionMetadata" },
249
+ "links" => { "$ref" => "##{SCHEMAS_PATH}/CollectionLinks" },
250
+ "data" => {
251
+ "type" => "array",
252
+ "items" => { "$ref" => build_schema(klass_name) }
253
+ }
254
+ }
255
+ }
256
+
257
+ "##{SCHEMAS_PATH}/#{collection_name}"
258
+ end
259
+
260
+ def openapi_show_description(klass_name)
261
+ {
262
+ "summary" => "Show an existing #{klass_name}",
263
+ "operationId" => "show#{klass_name}",
264
+ "description" => "Returns a #{klass_name} object",
265
+ "parameters" => [{ "$ref" => build_parameter("ID") }],
266
+ "responses" => {
267
+ "200" => {
268
+ "description" => "#{klass_name} info",
269
+ "content" => {
270
+ "application/json" => {
271
+ "schema" => { "$ref" => build_schema(klass_name) }
272
+ }
273
+ }
274
+ },
275
+ "404" => {
276
+ "description" => "Not found",
277
+ "content" => {
278
+ "application/json" => {
279
+ "schema" => { "$ref" => build_schema_error_not_found }
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ end
286
+
287
+ def openapi_destroy_description(klass_name)
288
+ {
289
+ "summary" => "Delete an existing #{klass_name}",
290
+ "operationId" => "delete#{klass_name}",
291
+ "description" => "Deletes a #{klass_name} object",
292
+ "parameters" => [{ "$ref" => build_parameter("ID") }],
293
+ "responses" => {
294
+ "204" => { "description" => "#{klass_name} deleted" },
295
+ "404" => {
296
+ "description" => "Not found",
297
+ "content" => {
298
+ "application/json" => {
299
+ "schema" => { "$ref" => build_schema_error_not_found }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ end
306
+
307
+ def openapi_create_description(klass_name)
308
+ {
309
+ "summary" => "Create a new #{klass_name}",
310
+ "operationId" => "create#{klass_name}",
311
+ "description" => "Creates a #{klass_name} object",
312
+ "requestBody" => {
313
+ "content" => {
314
+ "application/json" => {
315
+ "schema" => { "$ref" => build_schema(klass_name) }
316
+ }
317
+ },
318
+ "description" => "#{klass_name} attributes to create",
319
+ "required" => true
320
+ },
321
+ "responses" => {
322
+ "201" => {
323
+ "description" => "#{klass_name} creation successful",
324
+ "content" => {
325
+ "application/json" => {
326
+ "schema" => { "$ref" => build_schema(klass_name) }
327
+ }
328
+ }
329
+ }
330
+ }
331
+ }
332
+ end
333
+
334
+ def openapi_update_description(klass_name, verb)
335
+ action = verb == "patch" ? "Update" : "Replace"
336
+ {
337
+ "summary" => "#{action} an existing #{klass_name}",
338
+ "operationId" => "#{action.downcase}#{klass_name}",
339
+ "description" => "#{action}s a #{klass_name} object",
340
+ "parameters" => [
341
+ { "$ref" => build_parameter("ID") }
342
+ ],
343
+ "requestBody" => {
344
+ "content" => {
345
+ "application/json" => {
346
+ "schema" => { "$ref" => build_schema(klass_name) }
347
+ }
348
+ },
349
+ "description" => "#{klass_name} attributes to update",
350
+ "required" => true
351
+ },
352
+ "responses" => {
353
+ "204" => { "description" => "Updated, no content" },
354
+ "400" => { "description" => "Bad request" },
355
+ "404" => {
356
+ "description" => "Not found",
357
+ "content" => {
358
+ "application/json" => {
359
+ "schema" => { "$ref" => build_schema_error_not_found }
360
+ }
361
+ }
362
+ }
363
+ }
364
+ }
365
+ end
366
+
367
+ def openapi_schema_properties_value(klass_name, model, key, value)
368
+ if key == model.primary_key
369
+ {
370
+ "$ref" => "##{SCHEMAS_PATH}/ID"
371
+ }
372
+ elsif key.ends_with?("_id")
373
+ properties_value = {}
374
+ properties_value["$ref"] = if generator_read_only_definitions.include?(klass_name)
375
+ # Everything under providers data is read only for now
376
+ "##{SCHEMAS_PATH}/ID"
377
+ else
378
+ openapi_contents.dig(*path_parts(SCHEMAS_PATH), klass_name, "properties", key, "$ref") || "##{SCHEMAS_PATH}/ID"
379
+ end
380
+ properties_value
381
+ else
382
+ properties_value = {
383
+ "type" => "string"
384
+ }
385
+
386
+ case value.sql_type_metadata.type
387
+ when :datetime
388
+ properties_value["format"] = "date-time"
389
+ when :integer
390
+ properties_value["type"] = "integer"
391
+ when :float
392
+ properties_value["type"] = "number"
393
+ when :boolean
394
+ properties_value["type"] = "boolean"
395
+ when :jsonb
396
+ properties_value["type"] = "object"
397
+ ['type', 'items', 'properties', 'additionalProperties'].each do |property_key|
398
+ prop = openapi_contents.dig(*path_parts(SCHEMAS_PATH), klass_name, "properties", key, property_key)
399
+ properties_value[property_key] = prop unless prop.nil?
400
+ end
401
+ end
402
+
403
+ # Take existing attrs, that we won't generate
404
+ ['example', 'format', 'readOnly', 'title', 'description'].each do |property_key|
405
+ property_value = openapi_contents.dig(*path_parts(SCHEMAS_PATH), klass_name, "properties", key, property_key)
406
+ properties_value[property_key] = property_value if property_value
407
+ end
408
+
409
+ if generator_read_only_definitions.include?(klass_name) || generator_read_only_attributes.include?(key.to_sym)
410
+ # Everything under providers data is read only for now
411
+ properties_value['readOnly'] = true
412
+ end
413
+
414
+ properties_value.sort.to_h
415
+ end
416
+ end
417
+
418
+ def run(graphql = false)
419
+ new_content = openapi_contents.dup
420
+ new_content["paths"] = build_paths.sort.to_h
421
+ new_content["components"] ||= {}
422
+ new_content["components"]["schemas"] = schemas.sort.each_with_object({}) { |(name, val), h| h[name] = val || openapi_contents["components"]["schemas"][name] || {} }
423
+ new_content["components"]["parameters"] = parameters.sort.each_with_object({}) { |(name, val), h| h[name] = val || openapi_contents["components"]["parameters"][name] || {} }
424
+ File.write(openapi_file, JSON.pretty_generate(new_content) + "\n")
425
+ Insights::API::Common::GraphQL::Generator.generate(api_version, new_content) if graphql
426
+ end
427
+
428
+ def openapi_schema_properties(klass_name)
429
+ model = klass_name.constantize
430
+ model.columns_hash.map do |key, value|
431
+ unless (generator_blacklist_allowed_attributes[key.to_sym] || []).include?(klass_name)
432
+ next if generator_blacklist_attributes.include?(key.to_sym)
433
+ end
434
+
435
+ if generator_blacklist_substitute_attributes.include?(key.to_sym)
436
+ generator_blacklist_substitute_attributes[key.to_sym]
437
+ else
438
+ [key, openapi_schema_properties_value(klass_name, model, key, value)]
439
+ end
440
+ end.compact.sort.to_h
441
+ end
442
+
443
+ def generator_blacklist_attributes
444
+ @generator_blacklist_attributes ||= [
445
+ :resource_timestamp,
446
+ :resource_timestamps,
447
+ :resource_timestamps_max,
448
+ :tenant_id,
449
+ ].to_set.freeze
450
+ end
451
+
452
+ def generator_blacklist_allowed_attributes
453
+ @generator_blacklist_allowed_attributes ||= {}
454
+ end
455
+
456
+ def generator_blacklist_substitute_attributes
457
+ @generator_blacklist_substitute_attributes ||= {}
458
+ end
459
+
460
+ def generator_read_only_attributes
461
+ @generator_read_only_attributes ||= [
462
+ :archived_at,
463
+ :created_at,
464
+ :last_seen_at,
465
+ :updated_at,
466
+ ].to_set.freeze
467
+ end
468
+
469
+ def generator_read_only_definitions
470
+ @generator_read_only_definitions ||= [].to_set.freeze
471
+ end
472
+
473
+ def build_paths
474
+ applicable_rails_routes.each_with_object({}) do |route, expected_paths|
475
+ without_format = route.path.split("(.:format)").first
476
+ sub_path = without_format.split(base_path).last.sub(/:[_a-z]*id/, "{id}")
477
+ klass_name = route.controller.split("/").last.camelize.singularize
478
+ verb = route.verb.downcase
479
+ primary_collection = sub_path.split("/")[1].camelize.singularize
480
+
481
+ expected_paths[sub_path] ||= {}
482
+ expected_paths[sub_path][verb] =
483
+ case route.action
484
+ when "index" then openapi_list_description(klass_name, primary_collection)
485
+ when "show" then openapi_show_description(klass_name)
486
+ when "destroy" then openapi_destroy_description(klass_name)
487
+ when "create" then openapi_create_description(klass_name)
488
+ when "update" then openapi_update_description(klass_name, verb)
489
+ else handle_custom_route_action(route.action.camelize, verb, primary_collection)
490
+ end
491
+
492
+ next if expected_paths[sub_path][verb]
493
+
494
+ # If it's not generic action but a custom method like e.g. `post "order", :to => "service_plans#order"`, we will
495
+ # try to take existing schema, because the description, summary, etc. are likely to be custom.
496
+ expected_paths[sub_path][verb] =
497
+ case verb
498
+ when "post"
499
+ if sub_path == "/graphql" && route.action == "query"
500
+ schemas["GraphQLRequest"] = ::Insights::API::Common::GraphQL.openapi_graphql_request
501
+ schemas["GraphQLResponse"] = ::Insights::API::Common::GraphQL.openapi_graphql_response
502
+ ::Insights::API::Common::GraphQL.openapi_graphql_description
503
+ else
504
+ openapi_contents.dig("paths", sub_path, verb) || openapi_create_description(klass_name)
505
+ end
506
+ when "get"
507
+ openapi_contents.dig("paths", sub_path, verb) || openapi_show_description(klass_name)
508
+ else
509
+ openapi_contents.dig("paths", sub_path, verb)
510
+ end
511
+ end
512
+ end
513
+
514
+ def handle_custom_route_action(_route_action, _verb, _primary_collection)
515
+ end
516
+ end
517
+ end
518
+ end
519
+ end
520
+ end