class Insights::API::Common::OpenApi::Generator

Constants

PARAMETERS_PATH
SCHEMAS_PATH

Public Class Methods

new() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 46
def initialize
  app_prefix, app_name = server_base_path.match(/\A(.*)\/(.*)\/v\d+.\d+\z/).captures
  ENV['APP_NAME'] = app_name
  ENV['PATH_PREFIX'] = app_prefix
  Rails.application.reload_routes!
  @operation_id_hash = {}
end

Public Instance Methods

api_version() click to toggle source

Let's get the latest api version based on the openapi.json routes

# File lib/insights/api/common/open_api/generator.rb, line 17
def api_version
  @api_version ||= Rails.application.routes.routes.each_with_object([]) do |route, array|
    matches = ActionDispatch::Routing::RouteWrapper
              .new(route)
              .path.match(/\A.*\/v(\d+.\d+)\/openapi.json.*\z/)
    array << matches[1] if matches
  end.max
end
applicable_rails_routes() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 58
def applicable_rails_routes
  rails_routes.select { |i| i.path.start_with?(server_base_path) }
end
build_collection_schema(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 238
def build_collection_schema(klass_name)
  collection_name = "#{klass_name.pluralize}Collection"
  schemas[collection_name] = {
    "type"       => "object",
    "properties" => {
      "meta"  => { "$ref" => "##{SCHEMAS_PATH}/CollectionMetadata" },
      "links" => { "$ref" => "##{SCHEMAS_PATH}/CollectionLinks"    },
      "data"  => {
        "type"  => "array",
        "items" => { "$ref" => build_schema(klass_name) }
      }
    }
  }

  "##{SCHEMAS_PATH}/#{collection_name}"
end
build_parameter(name, value = nil) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 187
def build_parameter(name, value = nil)
  parameters[name] = value
  "##{PARAMETERS_PATH}/#{name}"
end
build_paths() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 515
def build_paths
  applicable_rails_routes.each_with_object({}) do |route, expected_paths|
    without_format     = route.path.split("(.:format)").first
    sub_path           = without_format.split(server_base_path).last.sub(/:[_a-z]*id/, "{id}")
    route_destination  = route.controller.split("/").last.camelize
    controller         = "Api::V#{api_version.sub(".", "x")}::#{route_destination}Controller".safe_constantize
    klass_name         = controller.try(:presentation_name) || route_destination.singularize
    verb               = route.verb.downcase
    primary_collection = sub_path.split("/")[1].camelize.singularize

    expected_paths[sub_path] ||= {}
    expected_paths[sub_path][verb] =
      case route.action
      when "index"   then openapi_list_description(klass_name, primary_collection)
      when "show"    then openapi_show_description(klass_name)
      when "destroy" then openapi_destroy_description(klass_name)
      when "create"  then openapi_create_description(klass_name)
      when "update"  then openapi_update_description(klass_name, verb)
      when "tag"     then openapi_tag_description(primary_collection)
      when "untag"   then openapi_untag_description(primary_collection)
      else handle_custom_route_action(route.action.camelize, verb, primary_collection)
      end

    next if expected_paths[sub_path][verb]

    # If it's not generic action but a custom method like e.g. `post "order", :to => "service_plans#order"`, we will
    # try to take existing schema, because the description, summary, etc. are likely to be custom.
    expected_paths[sub_path][verb] =
      case verb
      when "post"
        if sub_path == "/graphql" && route.action == "query"
          schemas["GraphQLRequest"]  = ::Insights::API::Common::GraphQL.openapi_graphql_request
          schemas["GraphQLResponse"] = ::Insights::API::Common::GraphQL.openapi_graphql_response
          ::Insights::API::Common::GraphQL.openapi_graphql_description
        else
          openapi_contents.dig("paths", sub_path, verb) || openapi_create_description(klass_name)
        end
      when "get"
        openapi_contents.dig("paths", sub_path, verb) || openapi_show_description(klass_name)
      else
        openapi_contents.dig("paths", sub_path, verb)
      end
  end
end
build_schema(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 104
def build_schema(klass_name)
  schemas[klass_name] = openapi_schema(klass_name)
  "##{SCHEMAS_PATH}/#{klass_name}"
end
build_schema_error_not_found() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 109
def build_schema_error_not_found
  klass_name = "ErrorNotFound"

  schemas[klass_name] = {
    "type"       => "object",
    "properties" => {
      "errors"  => {
        "type"  => "array",
        "items" => {
          "type"        => "object",
          "properties"  => {
            "status"    => {
              "type"    => "string",
              "example" => "404"
            },
            "detail"    => {
              "type"    => "string",
              "example" => "Record not found"
            }
          }
        }
      }
    }
  }

  "##{SCHEMAS_PATH}/#{klass_name}"
end
generator_blacklist_allowed_attributes() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 494
def generator_blacklist_allowed_attributes
  @generator_blacklist_allowed_attributes ||= {}
end
generator_blacklist_attributes() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 485
def generator_blacklist_attributes
  @generator_blacklist_attributes ||= [
    :resource_timestamp,
    :resource_timestamps,
    :resource_timestamps_max,
    :tenant_id,
  ].to_set.freeze
end
generator_blacklist_substitute_attributes() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 498
def generator_blacklist_substitute_attributes
  @generator_blacklist_substitute_attributes ||= {}
end
generator_read_only_attributes() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 502
def generator_read_only_attributes
  @generator_read_only_attributes ||= [
    :archived_at,
    :created_at,
    :last_seen_at,
    :updated_at,
  ].to_set.freeze
end
generator_read_only_definitions() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 511
def generator_read_only_definitions
  @generator_read_only_definitions ||= [].to_set.freeze
end
handle_custom_route_action(_route_action, _verb, _primary_collection) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 560
def handle_custom_route_action(_route_action, _verb, _primary_collection)
end
openapi_contents() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 40
def openapi_contents
  @openapi_contents ||= begin
    JSON.parse(File.read(openapi_file))
  end
end
openapi_create_description(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 349
def openapi_create_description(klass_name)
  {
    "summary"     => "Create a new #{klass_name}",
    "operationId" => "create#{klass_name}",
    "description" => "Creates a #{klass_name} object",
    "requestBody" => request_body(klass_name, "create"),
    "responses"   => {
      "201" => {
        "description" => "#{klass_name} creation successful",
        "content"     => {
          "application/json" => {
            "schema" => { "$ref" => build_schema(klass_name) }
          }
        }
      }
    }
  }
end
openapi_destroy_description(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 282
def openapi_destroy_description(klass_name)
  {
    "summary"     => "Delete an existing #{klass_name}",
    "operationId" => "delete#{klass_name}",
    "description" => "Deletes a #{klass_name} object",
    "parameters"  => [{ "$ref" => build_parameter("ID") }],
    "responses"   => {
      "204" => { "description" => "#{klass_name} deleted" },
      "404" => {
        "description" => "Not found",
        "content"     => {
          "application/json" => {
            "schema"         => { "$ref" => build_schema_error_not_found }
          }
        }
      }
    }
  }
end
openapi_file() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 36
def openapi_file
  @openapi_file ||= Rails.root.join("public", "doc", "openapi-3-v#{api_version}.json").to_s
end
openapi_list_description(klass_name, primary_collection) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 200
def openapi_list_description(klass_name, primary_collection)
  sub_collection = (primary_collection != klass_name)
  {
    "summary"     => "List #{klass_name.pluralize}#{" for #{primary_collection}" if sub_collection}",
    "operationId" => operation_id(klass_name, primary_collection, sub_collection),
    "description" => "Returns an array of #{klass_name} objects",
    "parameters"  => [
      { "$ref" => "##{PARAMETERS_PATH}/QueryLimit"  },
      { "$ref" => "##{PARAMETERS_PATH}/QueryOffset" },
      { "$ref" => "##{PARAMETERS_PATH}/QueryFilter" },
      { "$ref" => "##{PARAMETERS_PATH}/QuerySortBy" }
    ],
    "responses"   => {
      "200" => {
        "description" => "#{klass_name.pluralize} collection",
        "content"     => {
          "application/json" => {
            "schema" => { "$ref" => build_collection_schema(klass_name) }
          }
        }
      }
    }
  }.tap do |h|
    h["parameters"] << { "$ref" => build_parameter("ID") } if sub_collection

    next unless sub_collection

    h["responses"]["404"] = {
      "description" => "Not found",
      "content"     => {
        "application/json" => {
          "schema"         => { "$ref" => build_schema_error_not_found }
        }
      }
    }
  end
end
openapi_schema(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 192
def openapi_schema(klass_name)
  {
    "type"                 => "object",
    "properties"           => openapi_schema_properties(klass_name),
    "additionalProperties" => false
  }
end
openapi_schema_properties(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 468
def openapi_schema_properties(klass_name)
  model = klass_name.constantize
  model.columns_hash.map do |key, value|
    unless (generator_blacklist_allowed_attributes[key.to_sym] || []).include?(klass_name)
      next if generator_blacklist_attributes.include?(key.to_sym)
    end

    if generator_blacklist_substitute_attributes.include?(key.to_sym)
      generator_blacklist_substitute_attributes[key.to_sym]
    else
      [key, openapi_schema_properties_value(klass_name, model, key, value)]
    end
  end.compact.sort.to_h
rescue NameError
  openapi_contents["components"]["schemas"][klass_name]["properties"]
end
openapi_schema_properties_value(klass_name, model, key, value) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 407
def openapi_schema_properties_value(klass_name, model, key, value)
  if key == model.primary_key
    {
      "$ref" => "##{SCHEMAS_PATH}/ID"
    }
  elsif key.ends_with?("_id")
    properties_value = {}
    properties_value["$ref"] = if generator_read_only_definitions.include?(klass_name)
                                 # Everything under providers data is read only for now
                                 "##{SCHEMAS_PATH}/ID"
                               else
                                 openapi_contents.dig(*path_parts(SCHEMAS_PATH), klass_name, "properties", key, "$ref") || "##{SCHEMAS_PATH}/ID"
                               end
    properties_value
  else
    properties_value = {
      "type" => "string"
    }

    case value.sql_type_metadata.type
    when :datetime
      properties_value["format"] = "date-time"
    when :integer
      properties_value["type"] = "integer"
    when :float
      properties_value["type"] = "number"
    when :boolean
      properties_value["type"] = "boolean"
    when :jsonb
      properties_value["type"] = "object"
      ['type', 'items', 'properties', 'additionalProperties'].each do |property_key|
        prop = openapi_contents.dig(*path_parts(SCHEMAS_PATH), klass_name, "properties", key, property_key)
        properties_value[property_key] = prop unless prop.nil?
      end
    end

    # Take existing attrs, that we won't generate
    ['example', 'format', 'nullable', 'readOnly', 'title', 'description'].each do |property_key|
      property_value                 = openapi_contents.dig(*path_parts(SCHEMAS_PATH), klass_name, "properties", key, property_key)
      properties_value[property_key] = property_value if property_value
    end

    if generator_read_only_definitions.include?(klass_name) || generator_read_only_attributes.include?(key.to_sym)
      # Everything under providers data is read only for now
      properties_value['readOnly'] = true
    end

    properties_value.sort.to_h
  end
end
openapi_show_description(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 255
def openapi_show_description(klass_name)
  {
    "summary"     => "Show an existing #{klass_name}",
    "operationId" => "show#{klass_name}",
    "description" => "Returns a #{klass_name} object",
    "parameters"  => [{ "$ref" => build_parameter("ID") }],
    "responses"   => {
      "200" => {
        "description" => "#{klass_name} info",
        "content"     => {
          "application/json" => {
            "schema" => { "$ref" => build_schema(klass_name) }
          }
        }
      },
      "404" => {
        "description" => "Not found",
        "content"     => {
          "application/json" => {
            "schema"         => { "$ref" => build_schema_error_not_found }
          }
        }
      }
    }
  }
end
openapi_tag_description(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 302
def openapi_tag_description(klass_name)
  {
    "summary"     => "Tag a #{klass_name}",
    "operationId" => "tag#{klass_name}",
    "description" => "Tags a #{klass_name} object",
    "parameters"  => [
      { "$ref" => build_parameter("ID") }
    ],
    "requestBody" => request_body("Tag", "add", :single => false),
    "responses"   => {
      "201" => {
        "description" => "#{klass_name} tagged successful",
        "content"     => {
          "application/json" => {
            "schema" => {
              "type"  => "array",
              "items" => {
                "$ref" => build_schema("Tag")
              }
            }
          }
        }
      },
      "304" => {
        "description" => "Not modified"
      }
    }
  }
end
openapi_untag_description(klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 332
def openapi_untag_description(klass_name)
  {
    "summary"     => "Untag a #{klass_name}",
    "operationId" => "untag#{klass_name}",
    "description" => "Untags a #{klass_name} object",
    "parameters"  => [
      { "$ref" => build_parameter("ID") }
    ],
    "requestBody" => request_body("Tag", "removed", :single => false),
    "responses"   => {
      "204" => {
        "description" => "#{klass_name} untagged successfully",
      }
    }
  }
end
openapi_update_description(klass_name, verb) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 382
def openapi_update_description(klass_name, verb)
  action = verb == "patch" ? "Update" : "Replace"
  {
    "summary"     => "#{action} an existing #{klass_name}",
    "operationId" => "#{action.downcase}#{klass_name}",
    "description" => "#{action}s a #{klass_name} object",
    "parameters"  => [
      { "$ref" => build_parameter("ID") }
    ],
    "requestBody" => request_body(klass_name, "update"),
    "responses"   => {
      "204" => { "description" => "Updated, no content" },
      "400" => { "description" => "Bad request"         },
      "404" => {
        "description" => "Not found",
        "content"     => {
          "application/json" => {
            "schema"         => { "$ref" => build_schema_error_not_found }
          }
        }
      }
    }
  }
end
operation_id(klass_name, primary_collection, sub_collection) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 574
def operation_id(klass_name, primary_collection, sub_collection)
  klass = klass_name.constantize
  name = if klass.respond_to?(:list_operation_id)
           klass.send(:list_operation_id)
         else
           "list#{primary_collection if sub_collection}#{klass_name.pluralize}"
         end
  validate_operation_id(name, klass_name)
  name
end
parameters() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 137
def parameters
  @parameters ||= {
    "QueryFilter" => {
      "in"          => "query",
      "name"        => "filter",
      "description" => "Filter for querying collections.",
      "required"    => false,
      "style"       => "deepObject",
      "explode"     => true,
      "schema"      => {
        "type" => "object"
      }
    },
    "QueryLimit"  => {
      "in"          => "query",
      "name"        => "limit",
      "description" => "The numbers of items to return per page.",
      "required"    => false,
      "schema"      => {
        "type"    => "integer",
        "minimum" => 1,
        "maximum" => 1000,
        "default" => 100
      }
    },
    "QueryOffset" => {
      "in"          => "query",
      "name"        => "offset",
      "description" => "The number of items to skip before starting to collect the result set.",
      "required"    => false,
      "schema"      => {
        "type"    => "integer",
        "minimum" => 0,
        "default" => 0
      }
    },
    "QuerySortBy" => {
      "in"          => "query",
      "name"        => "sort_by",
      "description" => "The list of attribute and order to sort the result set by.",
      "required"    => false,
      "style"       => "deepObject",
      "explode"     => true,
      "schema"      => {
        "type" => "object"
      }
    }
  }
end
path_parts(openapi_path) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 12
def path_parts(openapi_path)
  openapi_path.split("/")[1..-1]
end
rails_routes() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 26
def rails_routes
  Rails.application.routes.routes.each_with_object([]) do |route, array|
    r = ActionDispatch::Routing::RouteWrapper.new(route)
    next if r.internal? # Don't display rails routes
    next if r.engine? # Don't care right now...

    array << r
  end
end
request_body(klass_name, action, single: true) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 368
def request_body(klass_name, action, single: true)
  schema = single ? { "$ref" => build_schema(klass_name) } : {"type" => "array", "items" => {"$ref" => build_schema(klass_name)}}

  {
    "content"     => {
      "application/json" => {
        "schema" => schema
      }
    },
    "description" => "#{klass_name} attributes to #{action}",
    "required"    => true
  }
end
run(graphql = false) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 458
def run(graphql = false)
  new_content = openapi_contents.dup
  new_content["paths"] = build_paths.sort.to_h
  new_content["components"] ||= {}
  new_content["components"]["schemas"]    = schemas.merge(schema_overrides).sort.each_with_object({}) { |(name, val), h| h[name] = val || openapi_contents["components"]["schemas"][name] || {} }
  new_content["components"]["parameters"] = parameters.sort.each_with_object({}) { |(name, val), h| h[name] = val || openapi_contents["components"]["parameters"][name] || {} }
  File.write(openapi_file, JSON.pretty_generate(new_content) + "\n")
  Insights::API::Common::GraphQL::Generator.generate(api_version, new_content) if graphql
end
schema_overrides() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 563
def schema_overrides
  {}
end
schemas() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 62
def schemas
  @schemas ||= {
    "CollectionLinks"    => {
      "type"       => "object",
      "properties" => {
        "first" => {
          "type" => "string"
        },
        "last"  => {
          "type" => "string"
        },
        "next"  => {
          "type" => "string"
        },
        "prev"  => {
          "type" => "string"
        },
      }
    },
    "CollectionMetadata" => {
      "type"       => "object",
      "properties" => {
        "count"  => {
          "type" => "integer"
        },
        "limit"  => {
          "type" => "integer"
        },
        "offset" => {
          "type" => "integer"
        }
      }
    },
    "ID"                 => {
      "type"        => "string",
      "description" => "ID of the resource",
      "pattern"     => "^\\d+$",
      "readOnly"    => true,
    }
  }
end
server_base_path() click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 54
def server_base_path
  openapi_contents["servers"].first["variables"]["basePath"]["default"]
end
validate_operation_id(name, klass_name) click to toggle source
# File lib/insights/api/common/open_api/generator.rb, line 567
def validate_operation_id(name, klass_name)
  if @operation_id_hash.key?(name)
    raise ArgumentError, "operation id cannot be duplicates, #{name} in class #{klass_name} has already been used in class #{@operation_id_hash[name]}"
  end
  @operation_id_hash[name] = klass_name
end