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,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