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,148 @@
1
+ require "erb"
2
+
3
+ module Insights
4
+ module API
5
+ module Common
6
+ module GraphQL
7
+ module Generator
8
+ PARAMETERS_PATH = "/components/parameters".freeze
9
+ SCHEMAS_PATH = "/components/schemas".freeze
10
+
11
+ def self.openapi_schema(openapi_doc, klass_name)
12
+ schemas = openapi_doc.content.dig(*path_parts(SCHEMAS_PATH))
13
+ [klass_name, "#{klass_name}Out"].each do |name|
14
+ schema = schemas[name]
15
+ return [name, schema] if schema
16
+ end
17
+ end
18
+
19
+ def self.path_parts(openapi_path)
20
+ openapi_path.split("/")[1..-1]
21
+ end
22
+
23
+ def self.template(type)
24
+ File.read(Pathname.new(__dir__).join(File.expand_path("templates", __dir__), "#{type}.erb").to_s)
25
+ end
26
+
27
+ def self.graphql_type(property_name, property_format, property_type)
28
+ return "!types.ID" if property_name == "id"
29
+
30
+ case property_type
31
+ when "string"
32
+ property_format == "date-time" ? "::Insights::API::Common::GraphQL::Types::DateTime" : "types.String"
33
+ when "number"
34
+ "types.Float"
35
+ when "boolean"
36
+ "types.Boolean"
37
+ when "integer"
38
+ "::Insights::API::Common::GraphQL::Types::BigInt"
39
+ end
40
+ end
41
+
42
+ def self.resource_associations(openapi_content, collection)
43
+ collection_is_associated = openapi_content["paths"].keys.any? do |path|
44
+ path.match?("^/[^/]*/{[[a-z]*_]*id}/#{collection}$") &&
45
+ openapi_content.dig("paths", path, "get").present?
46
+ end
47
+ collection_associations = []
48
+ openapi_content["paths"].keys.each do |path|
49
+ subcollection_match = path.match("^/#{collection}/{[[a-z]*_]*id}/([^/]*)$")
50
+ next unless subcollection_match
51
+
52
+ subcollection = subcollection_match[1]
53
+ next unless openapi_content["paths"].keys.any? do |subcollection_path|
54
+ subcollection_path.match?("^/#{subcollection}/{[[a-z]*_]*id}$") &&
55
+ openapi_content.dig("paths", subcollection_path, "get").present?
56
+ end
57
+
58
+ collection_associations << subcollection
59
+ end
60
+ [collection_is_associated ? true : false, collection_associations.sort]
61
+ end
62
+
63
+ def self.collection_field_resolvers(schema_overlay, collection)
64
+ field_resolvers = {}
65
+ schema_overlay.keys.each do |collection_regex|
66
+ next unless collection.match(collection_regex)
67
+
68
+ field_resolvers.merge!(schema_overlay.fetch_path(collection_regex, "field_resolvers") || {})
69
+ end
70
+ field_resolvers
71
+ end
72
+
73
+ def self.collection_schema_overlay(schema_overlay, collection)
74
+ schema_overlay.keys.each_with_object({}) do |collection_regex, collection_schema_overlay|
75
+ next unless collection.match?(collection_regex)
76
+
77
+ collection_schema_overlay.merge!(schema_overlay[collection_regex] || {})
78
+ end
79
+ end
80
+
81
+ def self.init_schema(request, schema_overlay = {})
82
+ api_version = ::Insights::API::Common::GraphQL.version(request)
83
+ version_namespace = "V#{api_version.tr('.', 'x')}"
84
+ openapi_doc = ::Insights::API::Common::OpenApi::Docs.instance[api_version]
85
+ openapi_content = openapi_doc.content
86
+
87
+ graphql_namespace = if ::Insights::API::Common::GraphQL::Api.const_defined?(version_namespace, false)
88
+ ::Insights::API::Common::GraphQL::Api.const_get(version_namespace)
89
+ else
90
+ ::Insights::API::Common::GraphQL::Api.const_set(version_namespace, Module.new)
91
+ end
92
+
93
+ return graphql_namespace.const_get("Schema") if graphql_namespace.const_defined?("Schema", false)
94
+
95
+ resources = openapi_content["paths"].keys.sort
96
+ collections = []
97
+ resources.each do |resource|
98
+ next unless openapi_content.dig("paths", resource, "get") # we only care for queries
99
+
100
+ rmatch = resource.match("^/(.*/)?([^/]*)/{[[a-z]*_]*id}$")
101
+ next unless rmatch
102
+
103
+ collection = rmatch[2]
104
+ klass_name = collection.camelize.singularize
105
+ next if graphql_namespace.const_defined?("#{klass_name}Type", false)
106
+
107
+ _schema_name, this_schema = openapi_schema(openapi_doc, klass_name)
108
+ next if this_schema.nil? || this_schema["type"] != "object" || this_schema["properties"].nil?
109
+
110
+ collections << collection
111
+
112
+ model_class = klass_name.constantize
113
+ model_encrypted_columns_set = (model_class.try(:encrypted_columns) || []).to_set
114
+
115
+ model_properties = []
116
+ properties = this_schema["properties"]
117
+ properties.keys.sort.each do |property_name|
118
+ next if model_encrypted_columns_set.include?(property_name)
119
+
120
+ property_schema = properties[property_name]
121
+ property_schema = openapi_content.dig(*path_parts(property_schema["$ref"])) if property_schema["$ref"]
122
+ property_format = property_schema["format"] || ""
123
+ property_type = property_schema["type"]
124
+ description = property_schema["description"]
125
+
126
+ property_graphql_type = graphql_type(property_name, property_format, property_type)
127
+ model_properties << [property_name, property_graphql_type, description] if property_graphql_type
128
+ end
129
+
130
+ field_resolvers = collection_field_resolvers(schema_overlay, klass_name)
131
+ model_is_associated, model_associations = resource_associations(openapi_content, collection)
132
+
133
+ graphql_model_type_template = ERB.new(template("model_type"), nil, '<>').result(binding)
134
+ graphql_namespace.module_eval(graphql_model_type_template)
135
+ end
136
+
137
+ graphql_query_type_template = ERB.new(template("query_type"), nil, '<>').result(binding)
138
+ graphql_namespace.module_eval(graphql_query_type_template)
139
+
140
+ graphql_schema_template = ERB.new(template("schema"), nil, '<>').result(binding)
141
+ graphql_namespace.module_eval(graphql_schema_template)
142
+ graphql_namespace.const_get("Schema")
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,35 @@
1
+ <%= klass_name %>Type = ::GraphQL::ObjectType.define do
2
+ name "<%= klass_name %>"
3
+ description "A <%= klass_name %>"
4
+
5
+ <% model_properties.each do |property| %>
6
+ <% property_name, property_type, property_description = property %>
7
+ <% field_description = property_description.nil? ? "" : ", \"#{property_description}\"" %>
8
+ <% field_resolver = "" %>
9
+ <% if field_resolvers[property_name].present? %>
10
+ <% field_resolver = "do\n resolve ->(obj, args, ctx) #{field_resolvers[property_name].strip}\n end"%>
11
+ <% end%>
12
+ field :<%= property_name %>, <%=property_type%> <%=field_description%> <%=field_resolver%>
13
+ <% end %>
14
+ <% if model_associations.present? %>
15
+ <% model_associations.each do |association| %>
16
+ <% associations = association.pluralize %>
17
+ <% association_class_name = association.camelize.singularize %>
18
+
19
+ field :<%= associations %> do
20
+ description "The <%= associations %> associated with this <%= klass_name %>"
21
+ type types[<%= "#{association_class_name}Type" %>]
22
+
23
+ argument :id, types.ID
24
+ argument :offset, types.Int, "The number of <%= associations %> to skip before starting to collect the result set"
25
+ argument :limit, types.Int, "The number of <%= associations %> to return"
26
+
27
+ preload :<%= associations %>
28
+
29
+ resolve lambda { |obj, args, _ctx|
30
+ ::Insights::API::Common::GraphQL::AssociationLoader.new(<%= klass_name.constantize %>, "<%= associations %>", args).load(obj)
31
+ }
32
+ end
33
+ <% end %>
34
+ <% end %>
35
+ end
@@ -0,0 +1,49 @@
1
+ QueryType = ::GraphQL::ObjectType.define do
2
+ name "Query"
3
+ description "The query root of this schema"
4
+
5
+ [
6
+ <%= collections.map { |c| ":#{c}" }.join(", ") %>
7
+ ].each do |collection|
8
+
9
+ klass_names = collection.to_s.camelize
10
+ klass_name = klass_names.singularize
11
+ model_class = klass_name.constantize
12
+ resource_type = "::Insights::API::Common::GraphQL::Api::#{version_namespace}::#{klass_name}Type".constantize
13
+
14
+ collection_schema_overlay = ::Insights::API::Common::GraphQL::Generator.collection_schema_overlay(schema_overlay, collection)
15
+ base_query = collection_schema_overlay["base_query"]
16
+
17
+ field collection do
18
+ description "List available #{collection}"
19
+ type types[resource_type]
20
+
21
+ argument :id, types.ID
22
+ argument :offset, types.Int, "The number of #{collection} to skip before starting to collect the result set"
23
+ argument :limit, types.Int, "The number of #{collection} to return"
24
+ argument :filter, ::Insights::API::Common::GraphQL::Types::QueryFilter, "The Query Filter for querying the #{collection}"
25
+ argument :sort_by, ::Insights::API::Common::GraphQL::Types::QuerySortBy, "The optional attributes to sort by. Provided as an array of attr[:asc] and attr:desc values"
26
+
27
+ resolve lambda { |_obj, args, ctx|
28
+ if base_query.present?
29
+ scope = base_query.call(model_class, ctx)
30
+ else
31
+ scope = model_class
32
+ end
33
+
34
+ if args[:filter]
35
+ openapi_doc = ::Insights::API::Common::OpenApi::Docs.instance["<%= api_version %>"]
36
+ openapi_schema_name, _schema = ::Insights::API::Common::GraphQL::Generator.openapi_schema(openapi_doc, klass_name)
37
+ scope = ::Insights::API::Common::Filter.new(
38
+ scope,
39
+ ActionController::Parameters.new(args[:filter]),
40
+ openapi_doc.definitions[openapi_schema_name]).apply
41
+ end
42
+ scope = ::Insights::API::Common::GraphQL.search_options(scope, args)
43
+ ::Insights::API::Common::PaginatedResponse.new(
44
+ base_query: scope, request: nil, limit: args[:limit], offset: args[:offset], sort_by: args[:sort_by]
45
+ ).records
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,6 @@
1
+ Schema = ::GraphQL::Schema.define do
2
+ use ::GraphQL::Batch
3
+ enable_preloading
4
+
5
+ query QueryType
6
+ end
@@ -0,0 +1,23 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module GraphQL
5
+ module Types
6
+ class BigInt < ::GraphQL::Schema::Scalar
7
+ description "Represents non-fractional signed whole numeric values. Since the value may exceed the size of a 32-bit integer, it's encoded as a string."
8
+
9
+ def self.coerce_input(value, _ctx)
10
+ Integer(value)
11
+ rescue ArgumentError
12
+ nil
13
+ end
14
+
15
+ def self.coerce_result(value, _ctx)
16
+ value.to_i.to_s
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module GraphQL
5
+ module Types
6
+ DateTime = ::GraphQL::ScalarType.define do
7
+ name "DateTime"
8
+ description "An ISO-8601 encoded UTC date string."
9
+
10
+ coerce_input ->(value, _ctx) { Time.zone.parse(value) }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module GraphQL
5
+ module Types
6
+ QueryFilter = ::GraphQL::ScalarType.define do
7
+ name "QueryFilter"
8
+ description "The Query Filter"
9
+
10
+ coerce_input ->(value, _ctx) { JSON.parse(value.to_json) }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module GraphQL
5
+ module Types
6
+ QuerySortBy = ::GraphQL::ScalarType.define do
7
+ name "QuerySortBy"
8
+ description "The Query SortBy"
9
+
10
+ coerce_input ->(value, _ctx) { JSON.parse(value.to_json) }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module Inflections
5
+ def self.load_inflections
6
+ @loaded ||= begin
7
+ load_common_inflections
8
+ true
9
+ end
10
+ end
11
+
12
+ def self.load_common_inflections
13
+ # Add new inflection rules using the following format
14
+ # (all these examples are active by default):
15
+ # ActiveSupport::Inflector.inflections do |inflect|
16
+ # inflect.plural /^(ox)$/i, '\1en'
17
+ # inflect.singular /^(ox)en/i, '\1'
18
+ # inflect.irregular 'person', 'people'
19
+ # inflect.uncountable %w( fish sheep )
20
+ # end
21
+ ActiveSupport::Inflector.inflections do |inflect|
22
+ inflect.acronym('ManageIQ')
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ class Logging
5
+ def self.activate(config)
6
+ require 'manageiq/loggers'
7
+ config.logger = if Rails.env.production?
8
+ config.colorize_logging = false
9
+ ManageIQ::Loggers::CloudWatch.new
10
+ else
11
+ ManageIQ::Loggers::Base.new(Rails.root.join("log", "#{Rails.env}.log"))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ class Metrics
5
+ def self.activate(config, prefix)
6
+ require 'prometheus_exporter'
7
+ require 'prometheus_exporter/client'
8
+
9
+ ensure_exporter_server
10
+ enable_in_process_metrics
11
+ enable_web_server_metrics(prefix)
12
+ end
13
+
14
+ private_class_method def self.ensure_exporter_server
15
+ require 'socket'
16
+ TCPSocket.open("localhost", 9394) {}
17
+ rescue Errno::ECONNREFUSED
18
+ require 'prometheus_exporter/server'
19
+ server = PrometheusExporter::Server::WebServer.new(port: 9394)
20
+ server.start
21
+
22
+ PrometheusExporter::Client.default = PrometheusExporter::LocalClient.new(collector: server.collector)
23
+ end
24
+
25
+ private_class_method def self.enable_in_process_metrics
26
+ require 'prometheus_exporter/instrumentation'
27
+
28
+ # this reports basic process metrics such as RSS and Ruby metrics
29
+ PrometheusExporter::Instrumentation::Process.start
30
+ end
31
+
32
+ private_class_method def self.enable_web_server_metrics(prefix)
33
+ require "insights/api/common/middleware/web_server_metrics"
34
+ Rails.application.middleware.unshift(Insights::API::Common::Middleware::WebServerMetrics, :metrics_prefix => prefix)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ module Insights
2
+ module API
3
+ module Common
4
+ module Middleware
5
+ class WebServerMetrics
6
+ def initialize(app, options = {})
7
+ @app = app
8
+ @metrics_prefix = options[:metrics_prefix] || "http_server"
9
+
10
+ require 'prometheus_exporter/client'
11
+ require 'prometheus_exporter/metric'
12
+
13
+ PrometheusExporter::Metric::Base.default_prefix = "#{@metrics_prefix}_"
14
+
15
+ @puma_busy_threads = PrometheusExporter::Client.default.register(:gauge, "puma_busy_threads", "The number of threads currently handling HTTP requests.")
16
+ @puma_max_threads = PrometheusExporter::Client.default.register(:gauge, "puma_max_threads", "The total number of threads able to handle HTTP requests.")
17
+ @request_counter = PrometheusExporter::Client.default.register(:counter, "requests_total", "The total number of HTTP requests handled by the Rack application.")
18
+ @request_histogram = PrometheusExporter::Client.default.register(:histogram, "request_duration_seconds", "The HTTP response duration of the Rack application.")
19
+ end
20
+
21
+ def call(env)
22
+ @puma_busy_threads.increment
23
+
24
+ result = nil
25
+ duration = Benchmark.realtime { result = @app.call(env) }
26
+ result
27
+ rescue => error
28
+ @error = error
29
+ raise
30
+ ensure
31
+ duration_labels = {
32
+ :method => env['REQUEST_METHOD'].downcase,
33
+ :path => strip_ids_from_path(env['PATH_INFO']),
34
+ }
35
+
36
+ counter_labels = duration_labels.merge(:code => result.first.to_s).tap do |labels|
37
+ labels[:exception] = @error.class.name if @error
38
+ end
39
+
40
+ @request_counter.increment(counter_labels)
41
+ @request_histogram.observe(duration, duration_labels)
42
+
43
+ @puma_max_threads.observe(puma_stats["max_threads"])
44
+ @puma_busy_threads.decrement
45
+ end
46
+
47
+ private
48
+
49
+ def strip_ids_from_path(path)
50
+ path.gsub(%r{/\d+(/|$)}, '/:id\\1')
51
+ end
52
+
53
+ def puma_stats
54
+ JSON.parse(Puma.stats)
55
+ rescue NoMethodError
56
+ {}
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end