Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Spec-Insert] Phase 2: Request and Response Body Tables #9241

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions spec-insert/config.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
param_table:
parameter_column:
freeform_text: _freeform field_
default_column:
empty_text: N/A
required_column:
Expand Down
118 changes: 76 additions & 42 deletions spec-insert/lib/api/action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,96 @@
# frozen_string_literal: true

require_relative 'parameter'
require_relative 'body'
require_relative 'operation'

# A collection of operations that comprise a single API Action
# AKA operation-group
class Action
# @param [SpecHash] spec Parsed OpenAPI spec
def self.actions=(spec)
operations = spec.paths.flat_map do |url, ops|
ops.filter_map { |verb, op| Operation.new(op, url, verb) unless op['x-ignorable'] }
module Api
# A collection of operations that comprise a single API Action
# AKA operation-group
class Action
SUCCESS_CODES = %w[200 201 202 203 204 205 206 207 208 226].freeze

# @param [SpecHash] spec Parsed OpenAPI spec
def self.actions=(spec)
operations = spec.paths.flat_map do |url, ops|
ops.filter_map { |verb, op| Operation.new(op, url, verb) unless op['x-ignorable'] }
end
@actions = operations.group_by(&:group).values.map { |ops| Action.new(ops) }.index_by(&:full_name)
end
@actions = operations.group_by(&:group).values.map { |ops| Action.new(ops) }.index_by(&:full_name)
end

# @return [Hash<String, Action>] API Actions indexed by operation-group
def self.actions
raise 'Actions not set' unless @actions
@actions
end
# @return [Hash<String, Action>] API Actions indexed by operation-group
def self.actions
raise 'Actions not set' unless @actions
@actions
end

# @return [Array<Operation>] Operations in the action
attr_reader :operations
# @return [Array<Api::Operation>] Operations in the action
attr_reader :operations

# @param [Array<Operation>] operations
def initialize(operations)
@operations = operations
@operation = operations.first
@spec = @operation&.spec
end
# @param [Array<Api::Operation>] operations
def initialize(operations)
@operations = operations
@operation = operations.first || {}
@spec = @operation&.spec
end

# @return [Array<Parameter>] Input arguments.
def arguments; @arguments ||= Parameter.from_operations(@operations.map(&:spec)); end
def query_parameters
@operations.map(&:spec).flat_map(&:parameters).filter { |param| !param['x-global'] && param.in == 'query' }
.group_by(&:name).values
.map { |params| Parameter.from_param_specs(params, @operations.size) }
end

# @return [String] Full name of the action (i.e. namespace.action)
def full_name; @operation&.group; end
def path_parameters
@operations.map(&:spec).flat_map(&:parameters).filter { |param| param.in == 'path' }
.group_by(&:name).values
.map { |params| Parameter.from_param_specs(params, @operations.size) }
end

# return [String] Name of the action
def name; @operation&.action; end
# @return [Api::Body, nil] Request body
def request_body
@request_body ||=
begin
operation = @operations.find { |op| op.spec.requestBody.present? }
required = @operations.all? { |op| op.spec.requestBody.required }
operation.nil? ? nil : Body.new(operation.spec.requestBody.content, required:)
end
end

# @return [Api::Body] Response body
def response_body
@response_body ||=
begin
spec = @operations.first.spec
code = SUCCESS_CODES.find { |c| spec.responses[c].present? }
Body.new(@operations.first.spec.responses[code].content, required: nil)
end
end

# @return [String] Namespace of the action
def namespace; @operation&.namespace; end
# @return [String] Full name of the action (i.e. namespace.action)
def full_name; @operation.group; end

# @return [Array<String>] Sorted unique HTTP verbs
def http_verbs; @operations.map(&:http_verb).uniq.sort; end
# return [String] Name of the action
def name; @operation.action; end

# @return [Array<String>] Unique URLs
def urls; @operations.map(&:url).uniq; end
# @return [String] Namespace of the action
def namespace; @operation.namespace; end

# @return [String] Description of the action
def description; @spec&.description; end
# @return [Array<String>] Sorted unique HTTP verbs
def http_verbs; @operations.map(&:http_verb).uniq.sort; end

# @return [Boolean] Whether the action is deprecated
def deprecated; @spec&.deprecated; end
# @return [Array<String>] Unique URLs
def urls; @operations.map(&:url).uniq; end

# @return [String] Deprecation message
def deprecation_message; @spec['x-deprecation-message']; end
# @return [String] Description of the action
def description; @spec.description; end

# @return [String] API reference
def api_reference; @operation&.external_docs&.url; end
# @return [Boolean] Whether the action is deprecated
def deprecated; @spec.deprecated; end

# @return [String] Deprecation message
def deprecation_message; @spec['x-deprecation-message']; end

# @return [String] API reference
def api_reference; @operation.external_docs.url; end
end
end
51 changes: 51 additions & 0 deletions spec-insert/lib/api/body.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require_relative 'parameter'
require_relative 'body_parameter'

module Api
# Request or response body
class Body
# @return [Boolean] Whether the body is in NDJSON format
attr_reader :ndjson

# @return [Boolean]
attr_reader :required

# @return [Array<Api::BodyParameterGroup>]
attr_reader :params_group

# @param [SpecHash] content
# @param [Boolean, nil] required
def initialize(content, required:)
@required = required
@ndjson = content['application/json'].nil?
spec = content['application/json'] || content['application/x-ndjson']
@params_group = BodyParameterGroup.from_schema(
flatten_schema(spec.schema),
description: spec.description || spec.schema.description,
ancestors: []
)
end

# @param [SpecHash] schema
# @return [SpecHash] a schema with allOf flattened
def flatten_schema(schema)
return schema if schema.type.present? && schema.type != 'object'
return schema if schema.properties.present?
return schema if schema.additionalProperties.present?
return schema.anyOf.map { |sch| flatten_schema(sch) } if schema.anyOf.present?
return schema.oneOf.map { |sch| flatten_schema(sch) } if schema.oneOf.present?
return schema if schema.allOf.blank?

schema = schema.allOf.each_with_object({ properties: {}, required: [] }) do |sch, h|
sch = flatten_schema(sch)
h[:properties].merge!(sch.properties || {})
h[:required] += sch.required || []
h[:additionalProperties] ||= sch.additionalProperties
end

SpecHash.new(schema, fully_parsed: true)
end
end
end
86 changes: 86 additions & 0 deletions spec-insert/lib/api/body_parameter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# frozen_string_literal: true

require_relative 'parameter'
require_relative '../config'

module Api
# Represents a group of parameters of an object within a request or response body
class BodyParameterGroup
def self.from_schema(schema, description:, ancestors:)
is_array = schema.type == 'array' || schema.items.present?
parameters = BodyParameter.from_schema(is_array ? schema.items : schema)
new(parameters:, ancestors:, description:, is_array:)
end

attr_reader :parameters, :ancestors, :description, :is_array

# @param [Array<Api::BodyParameter>] parameters
# @param [Array<String>] ancestors
# @param [String] description
# @param [Boolean] is_array
def initialize(parameters:, ancestors:, description:, is_array:)
@parameters = parameters
@ancestors = ancestors
@description = description
@is_array = is_array
parameters.each { |param| param.group = self }
end

# @return [Array<BodyParameterGroup>] The child groups of the group
def descendants
@parameters.map(&:child_params_group).compact.flat_map do |group|
[group] + group.descendants
end
end
end

# TODO: Handle cyclic references
# Represents a body parameter of different levels of a request or response body
class BodyParameter < Parameter
# @param [SpecHash] schema The schema of an object
# @return [Array<Api::BodyParameter>] The parameters of the object
def self.from_schema(schema)
properties = schema.properties || {}
parameters = properties.map do |name, prop|
BodyParameter.new(name:, schema: prop, required: schema.required&.include?(name))
end.sort { |a, b| a.name <=> b.name }
return parameters unless schema.additionalProperties
parameters + [BodyParameter.new(name: CONFIG.param_table.freeform_text, schema: schema.additionalProperties)]
end

attr_accessor :group

# @param [String] name
# @param [SpecHash] schema
# @param [Boolean] required
def initialize(name:, schema:, required: false)
super(name:,
required:,
schema:,
description: schema.description,
default: schema['default'],
deprecated: schema.deprecated || schema['x-version-deprecated'].present?,
version_deprecated: schema['x-version-deprecated'],
deprecation_message: schema['x-deprecation-message'])
@include_object = @doc_type.include?('Object')
end

# @return [BodyParameterGroup, nil] The parameters of the object
def child_params_group
return nil unless @include_object
return @child_params_group if defined?(@child_params_group)
@child_params_group ||= BodyParameterGroup.from_schema(
@schema,
ancestors: @group.ancestors + [@name],
description: @description
)
end

private

# TODO: Turn this into a configurable setting
def parse_array(schema)
"Array of #{parse_doc_type(schema.items).pluralize}"
end
end
end
48 changes: 25 additions & 23 deletions spec-insert/lib/api/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,31 @@

# frozen_string_literal: true

# An API Operation
class Operation
# @return [Openapi3Parser::Node::Operation] Operation Spec
attr_reader :spec
# @return [String] URL
attr_reader :url
# @return [String] HTTP Verb
attr_reader :http_verb
# @return [String] Operation Group
attr_reader :group
# @return [String] API Action
attr_reader :action
# @return [String] API Namespace
attr_reader :namespace
module Api
# An API Operation
class Operation
# @return [SpecHash] Operation Spec
attr_reader :spec
# @return [String] URL
attr_reader :url
# @return [String] HTTP Verb
attr_reader :http_verb
# @return [String] Operation Group
attr_reader :group
# @return [String] API Action
attr_reader :action
# @return [String] API Namespace
attr_reader :namespace

# @param [Openapi3Parser::Node::Operation] spec Operation Spec
# @param [String] url
# @param [String] http_verb
def initialize(spec, url, http_verb)
@spec = spec
@url = url
@http_verb = http_verb.upcase
@group = spec['x-operation-group']
@action, @namespace = @group.split('.').reverse
# @param [SpecHash] spec Operation Spec
# @param [String] url
# @param [String] http_verb
def initialize(spec, url, http_verb)
@spec = spec
@url = url
@http_verb = http_verb.upcase
@group = spec['x-operation-group']
@action, @namespace = @group.split('.').reverse
end
end
end
Loading
Loading