insights-api-common 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
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