Skip to content

Commit 83b14df

Browse files
authored
[Spec-Insert] Dry-run report tool to generate dry-run components for all API for review and debugging. (#9567)
* # refactored Signed-off-by: Theo Truong <theotr@amazon.com> * # linting Signed-off-by: Theo Truong <theotr@amazon.com> * # linting Signed-off-by: Theo Truong <theotr@amazon.com> --------- Signed-off-by: Theo Truong <theotr@amazon.com>
1 parent 90764f6 commit 83b14df

21 files changed

+299
-112
lines changed

DEVELOPER_GUIDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,12 @@ bundle exec rake generate_utilization_coverage
178178

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

181+
## Spec insert generate dry-run report
182+
183+
To generate a dry-run report of all APIs with all available spec insert components, run the following command:
184+
185+
```shell
186+
cd spec-insert
187+
bundle exec rake generate_dry_run_report
188+
```
189+
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.

spec-insert/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
opensearch-openapi.yaml
22
rspec_examples.txt
3-
utilization_coverage.md
3+
utilization_coverage.md
4+
dry_run_report.md
5+
dry-run/

spec-insert/Rakefile

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88

99
require 'rake'
1010
require 'active_support/all'
11-
require_relative 'lib/coverage/utilization_coverage'
11+
require_relative 'lib/reports/utilization_coverage'
12+
require_relative 'lib/reports/dry_run_report'
1213
require_relative 'lib/utils'
14+
require_relative 'lib/renderers/spec_insert'
15+
require_relative 'lib/insert_arguments'
1316

1417
desc 'Generate utilization coverage of Spec-Insert components'
1518
task :generate_utilization_coverage do
@@ -19,3 +22,23 @@ task :generate_utilization_coverage do
1922
File.write(file, coverage)
2023
puts "Utilization coverage written to #{file}"
2124
end
25+
26+
desc 'Generate all Spec-Insert components for all APIs and summarize the results'
27+
task :generate_dry_run_report do
28+
Utils.load_spec
29+
report = DryRunReport.new.render
30+
file = File.join(__dir__, 'dry_run_report.md')
31+
File.write(file, report)
32+
puts "Dry run report written to #{file}"
33+
end
34+
35+
desc 'Generate a specific component into the console'
36+
task :dry_run_generate, [:api, :component] do |_, args|
37+
Utils.load_spec
38+
render = SpecInsert.new(InsertArguments.new(args)).render
39+
output = "./dry-run/_#{args[:api]}_#{args[:component]}.md"
40+
File.write(output, render)
41+
42+
puts render
43+
puts "\n\nThe above render has been written to #{output}"
44+
end

spec-insert/lib/api/action.rb

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,23 @@ def path_parameters
6060
.map { |params| Parameter.from_param_specs(params, @operations.size) }
6161
end
6262

63-
# @return [Api::Body, nil] Request body
63+
# @return [Api::Body] Request body
6464
def request_body
65-
@request_body ||=
66-
begin
67-
operation = @operations.find { |op| op.spec.requestBody.present? }
68-
required = @operations.all? { |op| op.spec.requestBody.required }
69-
operation.nil? ? nil : Body.new(operation.spec.requestBody.content, required:)
70-
end
65+
@request_body ||= begin
66+
operation = @operations.find { |op| op.spec.requestBody.present? }
67+
required = @operations.all? { |op| op.spec.requestBody&.required }
68+
content = operation ? operation.spec.requestBody.content : nil
69+
Body.new(content, required:)
70+
end
7171
end
7272

7373
# @return [Api::Body] Response body
7474
def response_body
75-
@response_body ||=
76-
begin
77-
spec = @operations.first.spec
78-
code = SUCCESS_CODES.find { |c| spec.responses[c].present? }
79-
Body.new(@operations.first.spec.responses[code].content, required: nil)
80-
end
75+
@response_body ||= begin
76+
spec = @operations.first.spec
77+
code = SUCCESS_CODES.find { |c| spec.responses[c].present? }
78+
Body.new(spec.responses[code].content, required: nil)
79+
end
8180
end
8281

8382
# @return [String] Full name of the action (i.e. namespace.action)

spec-insert/lib/api/body.rb

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,46 +6,27 @@
66
module Api
77
# Request or response body
88
class Body
9-
# @return [Boolean] Whether the body is in NDJSON format
10-
attr_reader :ndjson
11-
9+
# @param [Boolean] empty whether a schema is defined
10+
attr_reader :empty
1211
# @return [Boolean]
1312
attr_reader :required
1413

15-
# @return [Array<Api::BodyParameterGroup>]
16-
attr_reader :params_group
17-
18-
# @param [SpecHash] content
14+
# @param [SpecHash, nil] content
1915
# @param [Boolean, nil] required
2016
def initialize(content, required:)
2117
@required = required
22-
@ndjson = content['application/json'].nil?
23-
spec = content['application/json'] || content['application/x-ndjson']
24-
@params_group = BodyParameterGroup.from_schema(
25-
flatten_schema(spec.schema),
26-
description: spec.description || spec.schema.description,
27-
ancestors: []
28-
)
18+
content ||= {}
19+
@spec = content['application/json'] || content['application/x-ndjson']
20+
@empty = @spec&.schema.nil?
2921
end
3022

31-
# @param [SpecHash] schema
32-
# @return [SpecHash] a schema with allOf flattened
33-
def flatten_schema(schema)
34-
return schema if schema.type.present? && schema.type != 'object'
35-
return schema if schema.properties.present?
36-
return schema if schema.additionalProperties.present?
37-
return schema.anyOf.map { |sch| flatten_schema(sch) } if schema.anyOf.present?
38-
return schema.oneOf.map { |sch| flatten_schema(sch) } if schema.oneOf.present?
39-
return schema if schema.allOf.blank?
40-
41-
schema = schema.allOf.each_with_object({ properties: {}, required: [] }) do |sch, h|
42-
sch = flatten_schema(sch)
43-
h[:properties].merge!(sch.properties || {})
44-
h[:required] += sch.required || []
45-
h[:additionalProperties] ||= sch.additionalProperties
46-
end
47-
48-
SpecHash.new(schema, fully_parsed: true)
23+
# @return [Api::BodyParameterGroup]
24+
def params_group
25+
@params_group ||= BodyParameterGroup.new(
26+
schema: @spec.schema,
27+
description: @spec.description || @spec.schema.description,
28+
ancestors: []
29+
)
4930
end
5031
end
5132
end

spec-insert/lib/api/body_parameter.rb

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,67 @@
66
module Api
77
# Represents a group of parameters of an object within a request or response body
88
class BodyParameterGroup
9-
def self.from_schema(schema, description:, ancestors:)
10-
is_array = schema.type == 'array' || schema.items.present?
11-
parameters = BodyParameter.from_schema(is_array ? schema.items : schema)
12-
new(parameters:, ancestors:, description:, is_array:)
13-
end
14-
15-
attr_reader :parameters, :ancestors, :description, :is_array
9+
attr_reader :members, :ancestors, :description, :is_array, :is_nested, :schema
1610

17-
# @param [Array<Api::BodyParameter>] parameters
11+
# @param [SpecHash] schema schema of an object or an array of objects
1812
# @param [Array<String>] ancestors
1913
# @param [String] description
20-
# @param [Boolean] is_array
21-
def initialize(parameters:, ancestors:, description:, is_array:)
22-
@parameters = parameters
14+
def initialize(schema:, ancestors:, description:)
2315
@ancestors = ancestors
2416
@description = description
25-
@is_array = is_array
26-
parameters.each { |param| param.group = self }
17+
@is_array = schema.items.present?
18+
@schema = @is_array ? schema.items : schema
19+
@schema = flatten_schema(@schema)
20+
@members = parse_members(@schema)
21+
@is_nested = @members.any? { |param| param.is_a?(BodyParameterGroup) }
22+
members.each { |param| param.group = self } unless @is_nested
2723
end
2824

2925
# @return [Array<BodyParameterGroup>] The child groups of the group
30-
def descendants
31-
@parameters.map(&:child_params_group).compact.flat_map do |group|
32-
[group] + group.descendants
26+
def descendants(seen_schemas = Set.new([@schema]))
27+
child_groups = @is_nested ? @members : @members.map(&:child_params_group).compact
28+
child_groups.reject { |g| seen_schemas.include?(g.schema) }.flat_map do |group|
29+
seen_schemas.add(group.schema)
30+
[group] + group.descendants(seen_schemas)
3331
end
3432
end
35-
end
3633

37-
# TODO: Handle cyclic references
38-
# Represents a body parameter of different levels of a request or response body
39-
class BodyParameter < Parameter
40-
# @param [SpecHash] schema The schema of an object
41-
# @return [Array<Api::BodyParameter>] The parameters of the object
42-
def self.from_schema(schema)
34+
# @param [SpecHash] schema
35+
# @return [Array<Api::BodyParameter>, Array<Api::BodyParameterGroup] members
36+
def parse_members(schema)
37+
union = schema.anyOf || schema.oneOf
38+
if union.present?
39+
return union.map { |sch| BodyParameterGroup.new(schema: sch, ancestors: @ancestors, description:) }
40+
end
4341
properties = schema.properties || {}
4442
parameters = properties.map do |name, prop|
4543
BodyParameter.new(name:, schema: prop, required: schema.required&.include?(name))
4644
end.sort { |a, b| a.name <=> b.name }
4745
return parameters unless schema.additionalProperties
48-
additional_schema = schema.additionalProperties == true ? {} : schema.additionalProperties
46+
additional_schema = schema.additionalProperties == true ? SpecHash.new({}) : schema.additionalProperties
4947
free_form_name = CONFIG.param_table.parameter_column.freeform_text
50-
parameters + [BodyParameter.new(name: free_form_name, schema: SpecHash.new(additional_schema))]
48+
parameters + [BodyParameter.new(name: free_form_name, schema: additional_schema)]
49+
end
50+
51+
# @param [SpecHash] schema
52+
# @return [SpecHash] a schema with allOf flattened
53+
def flatten_schema(schema)
54+
return schema if schema.allOf.blank?
55+
56+
schema = schema.allOf.each_with_object({ 'properties' => {}, 'required' => [] }) do |sch, h|
57+
sch = flatten_schema(sch)
58+
h['properties'].merge!(sch.properties || {})
59+
h['required'] += sch.required || []
60+
h['additionalProperties'] ||= sch.additionalProperties
61+
end
62+
63+
SpecHash.new(schema, fully_parsed: true)
5164
end
65+
end
5266

67+
# TODO: Handle cyclic references
68+
# Represents a body parameter of different levels of a request or response body
69+
class BodyParameter < Parameter
5370
attr_accessor :group
5471

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

70-
# @return [BodyParameterGroup, nil] The parameters of the object
87+
# @return [BodyParameterGroup, nil] The parameters group of an object parameter
7188
def child_params_group
7289
return nil unless @include_object
7390
return @child_params_group if defined?(@child_params_group)
74-
@child_params_group ||= BodyParameterGroup.from_schema(
75-
@schema,
91+
@child_params_group ||= BodyParameterGroup.new(
92+
schema: @schema,
7693
ancestors: @group.ancestors + [@name],
7794
description: @description
7895
)
@@ -82,6 +99,7 @@ def child_params_group
8299

83100
# TODO: Turn this into a configurable setting
84101
def parse_array(schema)
102+
return 'Array' if schema.items == true || schema.items.nil?
85103
"Array of #{parse_doc_type(schema.items).pluralize}"
86104
end
87105
end

spec-insert/lib/api/parameter.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def parse_doc_type(schema)
8282
return parse_array(schema) if type == 'array' || schema.items.present?
8383
return 'NULL' if type == 'null'
8484
return 'Object' if type == 'object' || type.nil?
85+
return type.map { |t| parse_doc_type(SpecHash.new({ 'type' => t })) }.uniq.sort.join(' or ') if type.is_a?(Array)
8586
raise "Unhandled JSON Schema Type: #{type}"
8687
end
8788

spec-insert/lib/doc_processor.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'pathname'
44
require_relative 'renderers/spec_insert'
55
require_relative 'spec_insert_error'
6+
require_relative 'insert_arguments'
67

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

5354
start_indices.zip(end_indices).map do |start, finish|
54-
[start, finish, SpecInsert.new(lines[start..finish])]
55+
args = InsertArguments.from_marker(lines[start..finish])
56+
[start, finish, SpecInsert.new(args)]
5557
end
5658
end
5759

spec-insert/lib/insert_arguments.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77
class InsertArguments
88
attr_reader :raw
99

10+
# @param [Hash] args raw arguments read from the doc insert marker
11+
def initialize(args)
12+
@raw = args.to_h.with_indifferent_access
13+
end
14+
1015
# @param [Array<String>] lines the lines between "<!-- doc_insert_start" and "<!-- spec_insert_end -->"
11-
def initialize(lines)
16+
# @return [InsertArguments]
17+
def self.from_marker(lines)
1218
end_index = lines.each_with_index.find { |line, _index| line.match?(/^\s*-->/) }&.last&.- 1
13-
@raw = lines[1..end_index].filter { |line| line.include?(':') }.to_h do |line|
19+
args = lines[1..end_index].filter { |line| line.include?(':') }.to_h do |line|
1420
key, value = line.split(':')
1521
[key.strip, value.strip]
1622
end
23+
new(args)
1724
end
1825

1926
# @return [String]

spec-insert/lib/renderers/body_parameters.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def initialize(action, args, is_request:)
1111
super(action, args)
1212
@is_request = is_request
1313
@body = is_request ? @action.request_body : @action.response_body
14-
@params_group = @body.params_group
14+
@empty = @body.empty
1515
end
1616

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

3232
def required
3333
@body.required
3434
end
3535

36-
def table
37-
ParameterTableRenderer.new(@params_group.parameters, @args, is_body: true).render
36+
def root_tables
37+
render_tables(@body.params_group)
3838
end
3939

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

4949
private
5050

51+
# @param [Api::BodyParameterGroup] group
52+
# @return [Array<String>]
53+
def render_tables(group)
54+
return group.members.flat_map { |g| render_tables(g) } if group.is_nested
55+
[ParameterTableRenderer.new(group.members, @args, is_body: true).render]
56+
end
57+
5158
# @param [Api::BodyParameterGroup] group
5259
def descendant_desc(group)
5360
schema_desc =

0 commit comments

Comments
 (0)