Skip to content

Commit a27c47f

Browse files
committed
ci: fix release artifact review findings
1 parent 49bc996 commit a27c47f

2 files changed

Lines changed: 178 additions & 177 deletions

File tree

.github/workflows/release_artifacts.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
run: git diff --exit-code -- public/openapi.yaml frontend/src/api/generated
5959

6060
refresh-generated-artifacts:
61-
if: github.event_name == 'push' && github.repository == 'html2rss/html2rss-web' && github.ref == 'refs/heads/release-please--branches--main'
61+
if: github.event_name == 'push' && github.repository == 'html2rss/html2rss-web' && github.ref == 'refs/heads/release-please--branches--main' && github.actor != 'github-actions[bot]'
6262
runs-on: ubuntu-latest
6363
permissions:
6464
contents: write

spec/support/openapi.rb

Lines changed: 177 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -1,207 +1,208 @@
11
# frozen_string_literal: true
22

3-
return unless ENV['OPENAPI']
4-
5-
require 'rspec/openapi'
6-
require_relative '../../config/version'
7-
8-
RSpec::OpenAPI.path = 'public/openapi.yaml'
9-
RSpec::OpenAPI.title = 'html2rss-web API'
10-
RSpec::OpenAPI.application_version = Html2rss::Web::VERSION
11-
RSpec::OpenAPI.enable_example = false
12-
RSpec::OpenAPI.enable_example_summary = false
13-
RSpec::OpenAPI.example_types = [:request]
14-
RSpec::OpenAPI.request_headers = ['Authorization']
15-
RSpec::OpenAPI.servers = [
16-
{ url: 'https://api.html2rss.dev/api/v1', description: 'Production server' },
17-
{ url: 'http://127.0.0.1:4000/api/v1', description: 'Development server' }
18-
]
19-
RSpec::OpenAPI.info = {
20-
description: 'RESTful API for converting websites to RSS feeds.',
21-
contact: {
22-
name: 'html2rss-web Support',
23-
url: 'https://github.com/html2rss/html2rss-web'
24-
},
25-
license: {
26-
name: 'MIT',
27-
url: 'https://opensource.org/licenses/MIT'
3+
if ENV['OPENAPI']
4+
require 'rspec/openapi'
5+
require_relative '../../config/version'
6+
7+
RSpec::OpenAPI.path = 'public/openapi.yaml'
8+
RSpec::OpenAPI.title = 'html2rss-web API'
9+
RSpec::OpenAPI.application_version = Html2rss::Web::VERSION
10+
RSpec::OpenAPI.enable_example = false
11+
RSpec::OpenAPI.enable_example_summary = false
12+
RSpec::OpenAPI.example_types = [:request]
13+
RSpec::OpenAPI.request_headers = ['Authorization']
14+
RSpec::OpenAPI.servers = [
15+
{ url: 'https://api.html2rss.dev/api/v1', description: 'Production server' },
16+
{ url: 'http://127.0.0.1:4000/api/v1', description: 'Development server' }
17+
]
18+
RSpec::OpenAPI.info = {
19+
description: 'RESTful API for converting websites to RSS feeds.',
20+
contact: {
21+
name: 'html2rss-web Support',
22+
url: 'https://github.com/html2rss/html2rss-web'
23+
},
24+
license: {
25+
name: 'MIT',
26+
url: 'https://opensource.org/licenses/MIT'
27+
}
2828
}
29-
}
30-
RSpec::OpenAPI.security_schemes = {
31-
'BearerAuth' => {
32-
description: 'Bearer token authentication for API access.',
33-
type: 'http',
34-
scheme: 'bearer',
35-
bearerFormat: 'JWT'
29+
RSpec::OpenAPI.security_schemes = {
30+
'BearerAuth' => {
31+
description: 'Bearer token authentication for API access.',
32+
type: 'http',
33+
scheme: 'bearer',
34+
bearerFormat: 'JWT'
35+
}
3636
}
37-
}
38-
39-
RSpec::OpenAPI.summary_builder = lambda { |example|
40-
example.metadata.dig(:example_group, :openapi, :summary) || example.metadata[:summary]
41-
}
42-
RSpec::OpenAPI.tags_builder = lambda { |example|
43-
example.metadata.dig(:example_group, :openapi, :tags) || example.metadata[:tags]
44-
}
45-
RSpec::OpenAPI.description_builder = lambda { |example|
46-
example.metadata.dig(:example_group, :openapi, :description) || example.metadata[:description] || example.description
47-
}
48-
49-
# Keep path keys relative to /api/v1 because servers include the versioned base path.
50-
RSpec::OpenAPI.post_process_hook = lambda do |_path, _records, spec|
51-
token_feed_error_statuses = %w[401 403 500].freeze
52-
53-
stringify = lambda do |value|
54-
case value
55-
when Hash
56-
value.each_with_object({}) { |(key, nested_value), mapped| mapped[key.to_s] = stringify.call(nested_value) }
57-
when Array
58-
value.map { |item| stringify.call(item) }
59-
else
60-
value
37+
38+
RSpec::OpenAPI.summary_builder = lambda { |example|
39+
example.metadata.dig(:example_group, :openapi, :summary) || example.metadata[:summary]
40+
}
41+
RSpec::OpenAPI.tags_builder = lambda { |example|
42+
example.metadata.dig(:example_group, :openapi, :tags) || example.metadata[:tags]
43+
}
44+
RSpec::OpenAPI.description_builder = lambda { |example|
45+
example.metadata.dig(:example_group, :openapi,
46+
:description) || example.metadata[:description] || example.description
47+
}
48+
49+
# Keep path keys relative to /api/v1 because servers include the versioned base path.
50+
RSpec::OpenAPI.post_process_hook = lambda do |_path, _records, spec|
51+
token_feed_error_statuses = %w[401 403 500].freeze
52+
53+
stringify = lambda do |value|
54+
case value
55+
when Hash
56+
value.each_with_object({}) { |(key, nested_value), mapped| mapped[key.to_s] = stringify.call(nested_value) }
57+
when Array
58+
value.map { |item| stringify.call(item) }
59+
else
60+
value
61+
end
6162
end
62-
end
6363

64-
deep_sort = lambda do |value|
65-
case value
66-
when Hash
67-
value.keys.sort_by(&:to_s).to_h { |key| [key, deep_sort.call(value[key])] }
68-
when Array
69-
value.map { |item| deep_sort.call(item) }
70-
else
71-
value
64+
deep_sort = lambda do |value|
65+
case value
66+
when Hash
67+
value.keys.sort_by(&:to_s).to_h { |key| [key, deep_sort.call(value[key])] }
68+
when Array
69+
value.map { |item| deep_sort.call(item) }
70+
else
71+
value
72+
end
7273
end
73-
end
7474

75-
merge_responses = lambda do |existing_responses, new_responses|
76-
canonical_description = lambda do |*responses|
77-
descriptions = responses
78-
.filter_map { |response| response['description']&.to_s&.strip }
79-
.reject(&:empty?)
80-
.uniq
75+
merge_responses = lambda do |existing_responses, new_responses|
76+
canonical_description = lambda do |*responses|
77+
descriptions = responses
78+
.filter_map { |response| response['description']&.to_s&.strip }
79+
.reject(&:empty?)
80+
.uniq
8181

82-
next nil if descriptions.empty?
82+
next nil if descriptions.empty?
8383

84-
# Prefer the most generic/canonical wording when duplicate examples define
85-
# the same status differently.
86-
descriptions.min_by { |description| [description.length, description] }
87-
end
84+
# Prefer the most generic/canonical wording when duplicate examples define
85+
# the same status differently.
86+
descriptions.min_by { |description| [description.length, description] }
87+
end
8888

89-
statuses = existing_responses.keys | new_responses.keys
90-
91-
statuses.each_with_object({}) do |status, merged_responses|
92-
current = existing_responses[status] || {}
93-
incoming = new_responses[status] || {}
94-
merged_response = current.merge(incoming)
95-
96-
current_content = current['content'] || {}
97-
incoming_content = incoming['content'] || {}
98-
if current_content.any? || incoming_content.any?
99-
content_types = current_content.keys | incoming_content.keys
100-
merged_response['content'] = content_types.to_h do |content_type|
101-
current_entry = current_content[content_type] || {}
102-
incoming_entry = incoming_content[content_type] || {}
103-
[content_type, current_entry.merge(incoming_entry)]
89+
statuses = existing_responses.keys | new_responses.keys
90+
91+
statuses.each_with_object({}) do |status, merged_responses|
92+
current = existing_responses[status] || {}
93+
incoming = new_responses[status] || {}
94+
merged_response = current.merge(incoming)
95+
96+
current_content = current['content'] || {}
97+
incoming_content = incoming['content'] || {}
98+
if current_content.any? || incoming_content.any?
99+
content_types = current_content.keys | incoming_content.keys
100+
merged_response['content'] = content_types.to_h do |content_type|
101+
current_entry = current_content[content_type] || {}
102+
incoming_entry = incoming_content[content_type] || {}
103+
[content_type, current_entry.merge(incoming_entry)]
104+
end
104105
end
105-
end
106106

107-
current_headers = current['headers'] || {}
108-
incoming_headers = incoming['headers'] || {}
109-
if current_headers.any? || incoming_headers.any?
110-
merged_response['headers'] = current_headers.merge(incoming_headers)
111-
end
107+
current_headers = current['headers'] || {}
108+
incoming_headers = incoming['headers'] || {}
109+
if current_headers.any? || incoming_headers.any?
110+
merged_response['headers'] = current_headers.merge(incoming_headers)
111+
end
112112

113-
merged_response['description'] = canonical_description.call(current, incoming)
114-
merged_responses[status] = merged_response
113+
merged_response['description'] = canonical_description.call(current, incoming)
114+
merged_responses[status] = merged_response
115+
end
115116
end
116-
end
117117

118-
token_feed_error_examples = {
119-
'application/xml' => {
120-
'example' => <<~XML.strip
121-
<?xml version="1.0" encoding="UTF-8"?>
122-
<rss version="2.0"><channel><title>Error</title><description>Internal Server Error</description></channel></rss>
123-
XML
124-
},
125-
'application/feed+json' => {
126-
'example' => '{"version":"https://jsonfeed.org/version/1.1","title":"Error"}'
118+
token_feed_error_examples = {
119+
'application/xml' => {
120+
'example' => <<~XML.strip
121+
<?xml version="1.0" encoding="UTF-8"?>
122+
<rss version="2.0"><channel><title>Error</title><description>Internal Server Error</description></channel></rss>
123+
XML
124+
},
125+
'application/feed+json' => {
126+
'example' => '{"version":"https://jsonfeed.org/version/1.1","title":"Error"}'
127+
}
127128
}
128-
}
129129

130-
path_map = spec['paths'] || spec[:paths]
131-
next unless path_map.is_a?(Hash)
132-
133-
normalized_paths = {}
134-
path_map.each do |raw_path, operation|
135-
original_path = raw_path.to_s
136-
normalized = if original_path.match?(%r{\A/api/v1/feeds/[^/]+\z})
137-
'/feeds/{token}'
138-
elsif original_path.start_with?('/api/v1')
139-
original_path.delete_prefix('/api/v1')
140-
else
141-
original_path
142-
end
143-
normalized = '/' if normalized.empty?
144-
normalized_paths[normalized] ||= {}
145-
146-
stringify.call(operation).each do |verb, operation_doc|
147-
existing = normalized_paths[normalized][verb]
148-
149-
if existing
150-
merged = existing.merge(operation_doc)
151-
merged['responses'] = merge_responses.call(existing['responses'] || {}, operation_doc['responses'] || {})
152-
merged['parameters'] = [*(existing['parameters'] || []), *(operation_doc['parameters'] || [])]
153-
merged['parameters'].uniq! { |parameter| [parameter['name'], parameter['in']] }
154-
normalized_paths[normalized][verb] = deep_sort.call(merged)
155-
else
156-
normalized_paths[normalized][verb] = deep_sort.call(operation_doc)
157-
end
130+
path_map = spec['paths'] || spec[:paths]
131+
next unless path_map.is_a?(Hash)
132+
133+
normalized_paths = {}
134+
path_map.each do |raw_path, operation|
135+
original_path = raw_path.to_s
136+
normalized = if original_path.match?(%r{\A/api/v1/feeds/[^/]+\z})
137+
'/feeds/{token}'
138+
elsif original_path.start_with?('/api/v1')
139+
original_path.delete_prefix('/api/v1')
140+
else
141+
original_path
142+
end
143+
normalized = '/' if normalized.empty?
144+
normalized_paths[normalized] ||= {}
145+
146+
stringify.call(operation).each do |verb, operation_doc|
147+
existing = normalized_paths[normalized][verb]
148+
149+
if existing
150+
merged = existing.merge(operation_doc)
151+
merged['responses'] = merge_responses.call(existing['responses'] || {}, operation_doc['responses'] || {})
152+
merged['parameters'] = [*(existing['parameters'] || []), *(operation_doc['parameters'] || [])]
153+
merged['parameters'].uniq! { |parameter| [parameter['name'], parameter['in']] }
154+
normalized_paths[normalized][verb] = deep_sort.call(merged)
155+
else
156+
normalized_paths[normalized][verb] = deep_sort.call(operation_doc)
157+
end
158158

159-
normalized_paths[normalized][verb]['description'] ||= normalized_paths[normalized][verb]['summary']
159+
normalized_paths[normalized][verb]['description'] ||= normalized_paths[normalized][verb]['summary']
160160

161-
next unless normalized == '/feeds/{token}'
161+
next unless normalized == '/feeds/{token}'
162162

163-
normalized_paths[normalized][verb]['parameters'] ||= []
164-
has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter|
165-
parameter['name'] == 'token' && parameter['in'] == 'path'
166-
end
167-
unless has_token_param
168-
normalized_paths[normalized][verb]['parameters'] << {
169-
'name' => 'token',
170-
'in' => 'path',
171-
'required' => true,
172-
'schema' => { 'type' => 'string' }
173-
}
174-
end
163+
normalized_paths[normalized][verb]['parameters'] ||= []
164+
has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter|
165+
parameter['name'] == 'token' && parameter['in'] == 'path'
166+
end
167+
unless has_token_param
168+
normalized_paths[normalized][verb]['parameters'] << {
169+
'name' => 'token',
170+
'in' => 'path',
171+
'required' => true,
172+
'schema' => { 'type' => 'string' }
173+
}
174+
end
175175

176-
token_feed_error_statuses.each do |status|
177-
response = normalized_paths[normalized][verb].dig('responses', status)
178-
next unless response
176+
token_feed_error_statuses.each do |status|
177+
response = normalized_paths[normalized][verb].dig('responses', status)
178+
next unless response
179179

180-
response['content'] ||= {}
181-
token_feed_error_examples.each do |content_type, example|
182-
response['content'][content_type] ||= { 'schema' => { 'type' => 'string' } }
183-
response['content'][content_type].merge!(example)
180+
response['content'] ||= {}
181+
token_feed_error_examples.each do |content_type, example|
182+
response['content'][content_type] ||= { 'schema' => { 'type' => 'string' } }
183+
response['content'][content_type].merge!(example)
184+
end
184185
end
185186
end
186187
end
187-
end
188188

189-
if spec.key?('paths')
190-
spec['paths'] = deep_sort.call(normalized_paths)
191-
else
192-
spec[:paths] = deep_sort.call(normalized_paths)
193-
end
189+
if spec.key?('paths')
190+
spec['paths'] = deep_sort.call(normalized_paths)
191+
else
192+
spec[:paths] = deep_sort.call(normalized_paths)
193+
end
194194

195-
tags = [
196-
{ 'name' => 'Root', 'description' => 'API metadata and service-level information.' },
197-
{ 'name' => 'Health', 'description' => 'Health and readiness endpoints.' },
198-
{ 'name' => 'Strategies', 'description' => 'Feed extraction strategy discovery.' },
199-
{ 'name' => 'Feeds', 'description' => 'Feed creation and feed rendering operations.' }
200-
]
195+
tags = [
196+
{ 'name' => 'Root', 'description' => 'API metadata and service-level information.' },
197+
{ 'name' => 'Health', 'description' => 'Health and readiness endpoints.' },
198+
{ 'name' => 'Strategies', 'description' => 'Feed extraction strategy discovery.' },
199+
{ 'name' => 'Feeds', 'description' => 'Feed creation and feed rendering operations.' }
200+
]
201201

202-
if spec.key?('tags')
203-
spec['tags'] = tags
204-
else
205-
spec[:tags] = tags
202+
if spec.key?('tags')
203+
spec['tags'] = tags
204+
else
205+
spec[:tags] = tags
206+
end
206207
end
207208
end

0 commit comments

Comments
 (0)