Skip to content

Commit a643238

Browse files
committed
PoC - Generate OpenApi Spec from Sourcecode
1 parent bb92b9e commit a643238

File tree

5 files changed

+11588
-0
lines changed

5 files changed

+11588
-0
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ end
7373

7474
group :test do
7575
gem 'codeclimate-test-reporter', '>= 1.0.8', require: false
76+
gem 'factory_bot_rails', require: false
7677
gem 'machinist', '~> 1.0.6'
7778
gem 'mock_redis'
7879
gem 'parallel_tests'
@@ -84,6 +85,7 @@ group :test do
8485
gem 'rspec-its'
8586
gem 'rspec-rails', '~> 8.0.1'
8687
gem 'rspec-wait'
88+
gem 'rswag-specs'
8789
gem 'rubocop', '~> 1.79.1'
8890
gem 'rubocop-capybara'
8991
gem 'rubocop-factory_bot'

Gemfile.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ GEM
147147
eventmachine (1.2.7)
148148
excon (1.2.7)
149149
logger
150+
factory_bot (6.5.4)
151+
activesupport (>= 6.1.0)
152+
factory_bot_rails (6.5.0)
153+
factory_bot (~> 6.5)
154+
railties (>= 6.1.0)
150155
faraday (0.17.6)
151156
multipart-post (>= 1.2, < 3)
152157
faraday-cookie_jar (0.0.7)
@@ -480,6 +485,11 @@ GEM
480485
activesupport (>= 3.0.0)
481486
mustache (~> 1.0, >= 0.99.4)
482487
rspec (~> 3.0)
488+
rswag-specs (2.16.0)
489+
activesupport (>= 5.2, < 8.1)
490+
json-schema (>= 2.2, < 6.0)
491+
railties (>= 5.2, < 8.1)
492+
rspec-core (>= 2.14)
483493
rubocop (1.79.1)
484494
json (~> 2.3)
485495
language_server-protocol (~> 3.17.0.2)
@@ -637,6 +647,7 @@ DEPENDENCIES
637647
debug (~> 1.11)
638648
digest-xxhash
639649
eventmachine (~> 1.2.7)
650+
factory_bot_rails
640651
fluent-logger
641652
fog-aliyun
642653
fog-aws
@@ -685,6 +696,7 @@ DEPENDENCIES
685696
rspec-rails (~> 8.0.1)
686697
rspec-wait
687698
rspec_api_documentation (>= 6.1.0)
699+
rswag-specs
688700
rubocop (~> 1.79.1)
689701
rubocop-capybara
690702
rubocop-factory_bot

lib/open_api_auto_generator.rb

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
require 'ostruct'
2+
3+
module OpenApiAutoGenerator
4+
def self.schema_from_message(message_class, openapi_spec)
5+
return nil unless message_class.respond_to?(:validators) && message_class.respond_to?(:allowed_keys)
6+
7+
schema = {
8+
'type' => 'object',
9+
'properties' => {},
10+
'required' => []
11+
}
12+
13+
message_class.validators.each do |validator|
14+
next unless validator.respond_to?(:attributes)
15+
16+
validator.attributes.each do |attr|
17+
schema['required'] << attr if validator.is_a?(ActiveModel::Validations::PresenceValidator)
18+
19+
schema['properties'][attr] = case validator
20+
when ActiveModel::Validations::PresenceValidator
21+
{ 'type' => 'string' } # Assuming string for presence validation
22+
when ActiveModel::Validations::FormatValidator
23+
validator.options[:with] ? { 'type' => 'string', 'pattern' => validator.options[:with].source } : { 'type' => 'string' }
24+
when ActiveModel::Validations::InclusionValidator
25+
{ 'type' => 'string', 'enum' => validator.options[:in] }
26+
when ActiveModel::Validations::NumericalityValidator
27+
{ 'type' => 'integer' }
28+
when VCAP::CloudController::AppCreateMessage::LifecycleValidator
29+
{ '$ref' => '#/components/schemas/Lifecycle' }
30+
when VCAP::CloudController::Validators::ArrayValidator
31+
{ 'type' => 'array', 'items' => { 'type' => 'string' } }
32+
when VCAP::CloudController::Validators::RelationshipValidator
33+
{ '$ref' => '#/components/schemas/Relationship' }
34+
else
35+
if defined?(BaseMessage) && validator.class.ancestors.include?(BaseMessage)
36+
nested_schema = schema_from_message(validator.class, openapi_spec)
37+
if nested_schema
38+
schema_name = "#{validator.class.name.demodulize}Request"
39+
openapi_spec['components']['schemas'][schema_name] ||= nested_schema
40+
{ '$ref' => "#/components/schemas/#{schema_name}" }
41+
else
42+
{ 'type' => 'object' }
43+
end
44+
else
45+
{ 'type' => 'string' }
46+
end
47+
end
48+
end
49+
end
50+
51+
message_class.allowed_keys.each do |key|
52+
schema['properties'][key] ||= { 'type' => 'string' }
53+
end
54+
55+
schema['properties']['lifecycle'] = { '$ref' => '#/components/schemas/BuildpackLifecycle' } if message_class.name == 'VCAP::CloudController::AppCreateMessage'
56+
57+
schema['required'].uniq!
58+
schema
59+
end
60+
61+
def self.schema_from_presenter(presenter_class)
62+
return nil unless presenter_class&.instance_methods&.include?(:to_hash)
63+
64+
# First try to get schema from actual instance data
65+
mock_model = mock_model_for_presenter(presenter_class)
66+
if mock_model
67+
begin
68+
presenter_instance = presenter_class.new(mock_model)
69+
hash_representation = presenter_instance.to_hash
70+
schema = generate_schema_from_hash(hash_representation)
71+
72+
# Enhance with database schema information if available
73+
if mock_model.respond_to?(:class) && mock_model.class.respond_to?(:db_schema)
74+
enhance_schema_with_db_info(schema, mock_model.class)
75+
end
76+
77+
return schema
78+
rescue StandardError => e
79+
puts "Warning: Could not generate schema from instance for #{presenter_class.name}: #{e.message}"
80+
end
81+
end
82+
83+
# Fallback: try to generate from presenter class structure
84+
generate_schema_from_presenter_class(presenter_class)
85+
rescue StandardError => e
86+
puts "Warning: Could not generate schema for #{presenter_class.name}: #{e.message}"
87+
nil
88+
end
89+
90+
def self.generate_schema_from_presenter_class(_presenter_class)
91+
# Try to analyze the presenter source code for common patterns
92+
schema = { 'type' => 'object', 'properties' => {} }
93+
94+
# Get common fields from base presenter or known patterns
95+
common_fields = %w[guid created_at updated_at name]
96+
common_fields.each do |field|
97+
schema['properties'][field] = infer_field_type(field)
98+
end
99+
100+
# Add metadata and links which are common in CF API
101+
schema['properties']['metadata'] = { '$ref' => '#/components/schemas/Metadata' }
102+
schema['properties']['links'] = { '$ref' => '#/components/schemas/Links' }
103+
104+
schema
105+
end
106+
107+
def self.infer_field_type(field_name)
108+
case field_name
109+
when 'guid'
110+
{ 'type' => 'string', 'format' => 'uuid' }
111+
when /.*_at$/, 'created_at', 'updated_at'
112+
{ 'type' => 'string', 'format' => 'date-time' }
113+
when 'name', 'description', 'title'
114+
{ 'type' => 'string' }
115+
when /.*_count$/, 'version'
116+
{ 'type' => 'integer' }
117+
when 'enabled', 'disabled', 'suspended'
118+
{ 'type' => 'boolean' }
119+
else
120+
{ 'type' => 'string' }
121+
end
122+
end
123+
124+
def self.enhance_schema_with_db_info(schema, model_class)
125+
return unless model_class.respond_to?(:db_schema)
126+
127+
model_class.db_schema.each do |column, db_info|
128+
column_str = column.to_s
129+
next unless schema['properties'][column_str]
130+
131+
schema['properties'][column_str]['type'] = db_type_to_openapi_type(db_info[:type])
132+
format = db_type_to_openapi_format(db_info[:type])
133+
schema['properties'][column_str]['format'] = format if format
134+
end
135+
end
136+
137+
def self.mock_model_for_presenter(presenter_class)
138+
class_name = presenter_class.name.demodulize.gsub('Presenter', '')
139+
140+
# Special case for InfoPresenter
141+
if presenter_class.name == 'VCAP::CloudController::Presenters::V3::InfoPresenter'
142+
# InfoPresenter is a special case
143+
begin
144+
info = Info.new
145+
config = VCAP::CloudController::Config.config
146+
if config
147+
info.build = config.get(:info, :build) || ''
148+
info.min_cli_version = config.get(:info, :min_cli_version) || ''
149+
info.min_recommended_cli_version = config.get(:info, :min_recommended_cli_version) || ''
150+
info.custom = config.get(:info, :custom) || {}
151+
info.description = config.get(:info, :description) || ''
152+
info.name = config.get(:info, :name) || ''
153+
info.version = config.get(:info, :version) || 0
154+
info.support_address = config.get(:info, :support_address) || ''
155+
else
156+
# If config is not available, set default values
157+
info.build = ''
158+
info.min_cli_version = ''
159+
info.min_recommended_cli_version = ''
160+
info.custom = {}
161+
info.description = ''
162+
info.name = ''
163+
info.version = 0
164+
info.support_address = ''
165+
end
166+
osbapi_version_file = Rails.root.join('config/osbapi_version').to_s
167+
info.osbapi_version = if File.exist?(osbapi_version_file)
168+
File.read(osbapi_version_file).strip
169+
else
170+
''
171+
end
172+
return info
173+
rescue StandardError => e
174+
puts "Warning: Could not create Info object: #{e.message}"
175+
# Fall back to a simple mock
176+
return OpenStruct.new(
177+
build: '',
178+
min_cli_version: '',
179+
min_recommended_cli_version: '',
180+
custom: {},
181+
description: '',
182+
name: '',
183+
version: 0,
184+
support_address: '',
185+
osbapi_version: ''
186+
)
187+
end
188+
end
189+
190+
# Try different factory names
191+
factory_names = [
192+
class_name.underscore,
193+
class_name.underscore.singularize,
194+
"#{class_name.underscore}_model"
195+
]
196+
197+
factory_names.each do |factory_name_str|
198+
factory_name = factory_name_str.to_sym
199+
next unless FactoryBot.factories.registered?(factory_name)
200+
201+
begin
202+
return FactoryBot.build(factory_name)
203+
rescue StandardError
204+
next
205+
end
206+
end
207+
208+
nil
209+
end
210+
211+
def self.mock_value_for_column(model_class, column_name)
212+
db_schema = model_class.db_schema[column_name]
213+
return nil unless db_schema
214+
215+
case db_schema[:type]
216+
when :string
217+
'string'
218+
when :integer
219+
1
220+
when :boolean
221+
true
222+
when :datetime
223+
Time.now.utc.iso8601
224+
else
225+
'unknown'
226+
end
227+
end
228+
229+
def self.generate_schema_from_hash(hash)
230+
properties = {}
231+
hash.each do |key, value|
232+
properties[key] = schema_for_value(value)
233+
end
234+
{ 'type' => 'object', 'properties' => properties }
235+
end
236+
237+
def self.db_type_to_openapi_type(db_type)
238+
case db_type
239+
when :string, :text
240+
'string'
241+
when :integer, :bigint
242+
'integer'
243+
when :boolean
244+
'boolean'
245+
when :datetime, :timestamp
246+
'string'
247+
when :float, :decimal
248+
'number'
249+
else
250+
'string'
251+
end
252+
end
253+
254+
def self.db_type_to_openapi_format(db_type)
255+
case db_type
256+
when :datetime, :timestamp
257+
'date-time'
258+
when :float
259+
'float'
260+
when :decimal
261+
'double'
262+
when :bigint
263+
'int64'
264+
else
265+
nil
266+
end
267+
end
268+
269+
def self.schema_for_value(value)
270+
case value
271+
when Hash
272+
generate_schema_from_hash(value)
273+
when Array
274+
items = value.empty? ? {} : schema_for_value(value.first)
275+
{ 'type' => 'array', 'items' => items }
276+
when String
277+
if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/)
278+
{ 'type' => 'string', 'format' => 'date-time' }
279+
elsif value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
280+
{ 'type' => 'string', 'format' => 'uuid' }
281+
else
282+
{ 'type' => 'string' }
283+
end
284+
when Integer
285+
{ 'type' => 'integer' }
286+
when TrueClass, FalseClass
287+
{ 'type' => 'boolean' }
288+
when NilClass
289+
{ 'type' => 'null' }
290+
else
291+
{ 'type' => 'string' }
292+
end
293+
end
294+
end

0 commit comments

Comments
 (0)