Skip to content
Merged
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
9 changes: 9 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,12 @@ bundle exec rake generate_utilization_coverage

The coverage report will be generated in the `spec-insert/utilization_coverage.md` by default.

## Spec insert generate dry-run report

To generate a dry-run report of all APIs with all available spec insert components, run the following command:

```shell
cd spec-insert
bundle exec rake generate_dry_run_report
```
This will also generate a markdown (.md) file for each API with their rendered components in the `spec-insert/dry_run` folder. This allows you to preview the rendered components for all APIs without modifying the original documentation files. A report summarizing the errors found during the dry-run will be generated in the `spec-insert/dry_run_report.md` file.
4 changes: 3 additions & 1 deletion spec-insert/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
opensearch-openapi.yaml
rspec_examples.txt
utilization_coverage.md
utilization_coverage.md
dry_run_report.md
dry-run/
25 changes: 24 additions & 1 deletion spec-insert/Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

require 'rake'
require 'active_support/all'
require_relative 'lib/coverage/utilization_coverage'
require_relative 'lib/reports/utilization_coverage'
require_relative 'lib/reports/dry_run_report'
require_relative 'lib/utils'
require_relative 'lib/renderers/spec_insert'
require_relative 'lib/insert_arguments'

desc 'Generate utilization coverage of Spec-Insert components'
task :generate_utilization_coverage do
Expand All @@ -19,3 +22,23 @@ task :generate_utilization_coverage do
File.write(file, coverage)
puts "Utilization coverage written to #{file}"
end

desc 'Generate all Spec-Insert components for all APIs and summarize the results'
task :generate_dry_run_report do
Utils.load_spec
report = DryRunReport.new.render
file = File.join(__dir__, 'dry_run_report.md')
File.write(file, report)
puts "Dry run report written to #{file}"
end

desc 'Generate a specific component into the console'
task :dry_run_generate, [:api, :component] do |_, args|
Utils.load_spec
render = SpecInsert.new(InsertArguments.new(args)).render
output = "./dry-run/_#{args[:api]}_#{args[:component]}.md"
File.write(output, render)

puts render
puts "\n\nThe above render has been written to #{output}"
end
25 changes: 12 additions & 13 deletions spec-insert/lib/api/action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,23 @@ def path_parameters
.map { |params| Parameter.from_param_specs(params, @operations.size) }
end

# @return [Api::Body, nil] Request body
# @return [Api::Body] 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
@request_body ||= begin
operation = @operations.find { |op| op.spec.requestBody.present? }
required = @operations.all? { |op| op.spec.requestBody&.required }
content = operation ? operation.spec.requestBody.content : nil
Body.new(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
@response_body ||= begin
spec = @operations.first.spec
code = SUCCESS_CODES.find { |c| spec.responses[c].present? }
Body.new(spec.responses[code].content, required: nil)
end
end

# @return [String] Full name of the action (i.e. namespace.action)
Expand Down
45 changes: 13 additions & 32 deletions spec-insert/lib/api/body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,27 @@
module Api
# Request or response body
class Body
# @return [Boolean] Whether the body is in NDJSON format
attr_reader :ndjson

# @param [Boolean] empty whether a schema is defined
attr_reader :empty
# @return [Boolean]
attr_reader :required

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

# @param [SpecHash] content
# @param [SpecHash, nil] 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: []
)
content ||= {}
@spec = content['application/json'] || content['application/x-ndjson']
@empty = @spec&.schema.nil?
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)
# @return [Api::BodyParameterGroup]
def params_group
@params_group ||= BodyParameterGroup.new(
schema: @spec.schema,
description: @spec.description || @spec.schema.description,
ancestors: []
)
end
end
end
74 changes: 46 additions & 28 deletions spec-insert/lib/api/body_parameter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,67 @@
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
attr_reader :members, :ancestors, :description, :is_array, :is_nested, :schema

# @param [Array<Api::BodyParameter>] parameters
# @param [SpecHash] schema schema of an object or an array of objects
# @param [Array<String>] ancestors
# @param [String] description
# @param [Boolean] is_array
def initialize(parameters:, ancestors:, description:, is_array:)
@parameters = parameters
def initialize(schema:, ancestors:, description:)
@ancestors = ancestors
@description = description
@is_array = is_array
parameters.each { |param| param.group = self }
@is_array = schema.items.present?
@schema = @is_array ? schema.items : schema
@schema = flatten_schema(@schema)
@members = parse_members(@schema)
@is_nested = @members.any? { |param| param.is_a?(BodyParameterGroup) }
members.each { |param| param.group = self } unless @is_nested
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
def descendants(seen_schemas = Set.new([@schema]))
child_groups = @is_nested ? @members : @members.map(&:child_params_group).compact
child_groups.reject { |g| seen_schemas.include?(g.schema) }.flat_map do |group|
seen_schemas.add(group.schema)
[group] + group.descendants(seen_schemas)
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)
# @param [SpecHash] schema
# @return [Array<Api::BodyParameter>, Array<Api::BodyParameterGroup] members
def parse_members(schema)
union = schema.anyOf || schema.oneOf
if union.present?
return union.map { |sch| BodyParameterGroup.new(schema: sch, ancestors: @ancestors, description:) }
end
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
additional_schema = schema.additionalProperties == true ? {} : schema.additionalProperties
additional_schema = schema.additionalProperties == true ? SpecHash.new({}) : schema.additionalProperties
free_form_name = CONFIG.param_table.parameter_column.freeform_text
parameters + [BodyParameter.new(name: free_form_name, schema: SpecHash.new(additional_schema))]
parameters + [BodyParameter.new(name: free_form_name, schema: additional_schema)]
end

# @param [SpecHash] schema
# @return [SpecHash] a schema with allOf flattened
def flatten_schema(schema)
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

# TODO: Handle cyclic references
# Represents a body parameter of different levels of a request or response body
class BodyParameter < Parameter
attr_accessor :group

# @param [String] name
Expand All @@ -67,12 +84,12 @@ def initialize(name:, schema:, required: false)
@include_object = @doc_type.include?('Object')
end

# @return [BodyParameterGroup, nil] The parameters of the object
# @return [BodyParameterGroup, nil] The parameters group of an object parameter
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,
@child_params_group ||= BodyParameterGroup.new(
schema: @schema,
ancestors: @group.ancestors + [@name],
description: @description
)
Expand All @@ -82,6 +99,7 @@ def child_params_group

# TODO: Turn this into a configurable setting
def parse_array(schema)
return 'Array' if schema.items == true || schema.items.nil?
"Array of #{parse_doc_type(schema.items).pluralize}"
end
end
Expand Down
1 change: 1 addition & 0 deletions spec-insert/lib/api/parameter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def parse_doc_type(schema)
return parse_array(schema) if type == 'array' || schema.items.present?
return 'NULL' if type == 'null'
return 'Object' if type == 'object' || type.nil?
return type.map { |t| parse_doc_type(SpecHash.new({ 'type' => t })) }.uniq.sort.join(' or ') if type.is_a?(Array)
raise "Unhandled JSON Schema Type: #{type}"
end

Expand Down
4 changes: 3 additions & 1 deletion spec-insert/lib/doc_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'pathname'
require_relative 'renderers/spec_insert'
require_relative 'spec_insert_error'
require_relative 'insert_arguments'

# Processes a file, replacing spec-insert blocks with rendered content
class DocProcessor
Expand Down Expand Up @@ -51,7 +52,8 @@ def find_insertions(lines)
validate_markers!(start_indices, end_indices)

start_indices.zip(end_indices).map do |start, finish|
[start, finish, SpecInsert.new(lines[start..finish])]
args = InsertArguments.from_marker(lines[start..finish])
[start, finish, SpecInsert.new(args)]
end
end

Expand Down
11 changes: 9 additions & 2 deletions spec-insert/lib/insert_arguments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
class InsertArguments
attr_reader :raw

# @param [Hash] args raw arguments read from the doc insert marker
def initialize(args)
@raw = args.to_h.with_indifferent_access
end

# @param [Array<String>] lines the lines between "<!-- doc_insert_start" and "<!-- spec_insert_end -->"
def initialize(lines)
# @return [InsertArguments]
def self.from_marker(lines)
end_index = lines.each_with_index.find { |line, _index| line.match?(/^\s*-->/) }&.last&.- 1
@raw = lines[1..end_index].filter { |line| line.include?(':') }.to_h do |line|
args = lines[1..end_index].filter { |line| line.include?(':') }.to_h do |line|
key, value = line.split(':')
[key.strip, value.strip]
end
new(args)
end

# @return [String]
Expand Down
21 changes: 14 additions & 7 deletions spec-insert/lib/renderers/body_parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def initialize(action, args, is_request:)
super(action, args)
@is_request = is_request
@body = is_request ? @action.request_body : @action.response_body
@params_group = @body.params_group
@empty = @body.empty
end

def header
Expand All @@ -21,33 +21,40 @@ def header
def description
name = "The #{@is_request ? 'request' : 'response'} body"
required = @body.required ? ' is __required__. It' : ' is optional. It' if @is_request
schema_desc = if @params_group.is_array
schema_desc = if @body.params_group.is_array
"#{name}#{required} is an __array of JSON objects__ (NDJSON). Each object has the following fields."
else
"#{name}#{required} is a JSON object with the following fields."
end
[@params_group.description, schema_desc].compact.reject(&:empty?).join("\n\n")
[@body.params_group.description, schema_desc].compact.reject(&:empty?).join("\n\n")
end

def required
@body.required
end

def table
ParameterTableRenderer.new(@params_group.parameters, @args, is_body: true).render
def root_tables
render_tables(@body.params_group)
end

def descendants
@params_group.descendants.sort_by(&:ancestors).map do |group|
@body.params_group.descendants.map do |group|
{ block_name: "#{@args.api}::#{@is_request ? 'request' : 'response'}_body",
summary: "#{header}: <code>#{group.ancestors.join('</code> > <code>')}</code>",
description: descendant_desc(group),
table: ParameterTableRenderer.new(group.parameters, @args, is_body: true).render }
descendant_tables: render_tables(group) }
end
end

private

# @param [Api::BodyParameterGroup] group
# @return [Array<String>]
def render_tables(group)
return group.members.flat_map { |g| render_tables(g) } if group.is_nested
[ParameterTableRenderer.new(group.members, @args, is_body: true).render]
end

# @param [Api::BodyParameterGroup] group
def descendant_desc(group)
schema_desc =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def initialize(action, args)
@args = args
end

def render
@empty ? nil : super
end

def omit_header
@args.omit_header
end
Expand Down
Loading
Loading