Skip to content

Commit b0a243a

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

File tree

5 files changed

+9473
-0
lines changed

5 files changed

+9473
-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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
module OpenApiAutoGenerator
2+
def self.schema_from_message(message_class, openapi_spec)
3+
return nil unless message_class&.respond_to?(:validators) && message_class.respond_to?(:allowed_keys)
4+
5+
schema = {
6+
'type' => 'object',
7+
'properties' => {},
8+
'required' => []
9+
}
10+
11+
message_class.validators.each do |validator|
12+
next unless validator.respond_to?(:attributes)
13+
14+
validator.attributes.each do |attr|
15+
schema['required'] << attr if validator.is_a?(ActiveModel::Validations::PresenceValidator)
16+
17+
schema['properties'][attr] = case validator
18+
when ActiveModel::Validations::PresenceValidator
19+
{ 'type' => 'string' } # Assuming string for presence validation
20+
when ActiveModel::Validations::FormatValidator
21+
validator.options[:with] ? { 'type' => 'string', 'pattern' => validator.options[:with].source } : { 'type' => 'string' }
22+
when ActiveModel::Validations::InclusionValidator
23+
{ 'type' => 'string', 'enum' => validator.options[:in] }
24+
when ActiveModel::Validations::NumericalityValidator
25+
{ 'type' => 'integer' }
26+
when VCAP::CloudController::AppCreateMessage::LifecycleValidator
27+
{ '$ref' => '#/components/schemas/Lifecycle' }
28+
when VCAP::CloudController::Validators::ArrayValidator
29+
{ 'type' => 'array', 'items' => { 'type' => 'string' } }
30+
when VCAP::CloudController::Validators::RelationshipValidator
31+
{ '$ref' => '#/components/schemas/Relationship' }
32+
else
33+
if defined?(BaseMessage) && validator.class.ancestors.include?(BaseMessage)
34+
nested_schema = schema_from_message(validator.class, openapi_spec)
35+
if nested_schema
36+
schema_name = "#{validator.class.name.demodulize}Request"
37+
openapi_spec['components']['schemas'][schema_name] ||= nested_schema
38+
{ '$ref' => "#/components/schemas/#{schema_name}" }
39+
else
40+
{ 'type' => 'object' }
41+
end
42+
else
43+
{ 'type' => 'string' }
44+
end
45+
end
46+
end
47+
end
48+
49+
message_class.allowed_keys.each do |key|
50+
schema['properties'][key] ||= { 'type' => 'string' }
51+
end
52+
53+
schema['properties']['lifecycle'] = { '$ref' => '#/components/schemas/BuildpackLifecycle' } if message_class.name == 'VCAP::CloudController::AppCreateMessage'
54+
55+
schema['required'].uniq!
56+
schema
57+
end
58+
59+
def self.schema_from_presenter(presenter_class)
60+
return nil unless presenter_class&.instance_methods&.include?(:to_hash)
61+
62+
if presenter_class.name == 'VCAP::CloudController::Presenters::V3::AppPresenter'
63+
return {
64+
'type' => 'object',
65+
'properties' => {
66+
'guid' => { 'type' => 'string', 'format' => 'uuid' },
67+
'created_at' => { 'type' => 'string', 'format' => 'date-time' },
68+
'updated_at' => { 'type' => 'string', 'format' => 'date-time' },
69+
'name' => { 'type' => 'string' },
70+
'state' => { 'type' => 'string' },
71+
'lifecycle' => { '$ref' => '#/components/schemas/Lifecycle' },
72+
'relationships' => { '$ref' => '#/components/schemas/Relationships' },
73+
'metadata' => { '$ref' => '#/components/schemas/Metadata' },
74+
'links' => { '$ref' => '#/components/schemas/Links' }
75+
}
76+
}
77+
end
78+
79+
if presenter_class.name == 'VCAP::CloudController::Presenters::V3::InfoPresenter'
80+
# InfoPresenter is a special case
81+
info = Info.new
82+
config = VCAP::CloudController::Config.config
83+
info.build = config.get(:info, :build) || ''
84+
info.min_cli_version = config.get(:info, :min_cli_version) || ''
85+
info.min_recommended_cli_version = config.get(:info, :min_recommended_cli_version) || ''
86+
info.custom = config.get(:info, :custom) || {}
87+
info.description = config.get(:info, :description) || ''
88+
info.name = config.get(:info, :name) || ''
89+
info.version = config.get(:info, :version) || 0
90+
info.support_address = config.get(:info, :support_address) || ''
91+
osbapi_version_file = Rails.root.join('config/osbapi_version').to_s
92+
info.osbapi_version = if File.exist?(osbapi_version_file)
93+
File.read(osbapi_version_file).strip
94+
else
95+
''
96+
end
97+
presenter_instance = presenter_class.new(info)
98+
hash_representation = presenter_instance.to_hash
99+
return generate_schema_from_hash(hash_representation)
100+
end
101+
102+
mock_model = mock_model_for_presenter(presenter_class)
103+
return nil unless mock_model
104+
105+
presenter_instance = presenter_class.new(mock_model)
106+
hash_representation = presenter_instance.to_hash
107+
schema = generate_schema_from_hash(hash_representation)
108+
109+
# Enhance schema with database types
110+
model_class = mock_model.class
111+
if model_class.respond_to?(:db_schema)
112+
model_class.db_schema.each do |column, db_info|
113+
if schema['properties'][column]
114+
schema['properties'][column]['type'] = db_type_to_openapi_type(db_info[:type])
115+
schema['properties'][column]['format'] = db_type_to_openapi_format(db_info[:type])
116+
end
117+
end
118+
end
119+
120+
schema
121+
rescue StandardError
122+
nil
123+
end
124+
125+
def self.mock_model_for_presenter(presenter_class)
126+
class_name = presenter_class.name.demodulize.gsub('Presenter', '')
127+
factory_name = class_name.underscore.to_sym
128+
return nil unless FactoryBot.factories.registered?(factory_name)
129+
130+
FactoryBot.create(factory_name)
131+
end
132+
133+
def self.mock_value_for_column(model_class, column_name)
134+
db_schema = model_class.db_schema[column_name]
135+
return nil unless db_schema
136+
137+
case db_schema[:type]
138+
when :string
139+
'string'
140+
when :integer
141+
1
142+
when :boolean
143+
true
144+
when :datetime
145+
Time.now.utc.iso8601
146+
else
147+
'unknown'
148+
end
149+
end
150+
151+
def self.generate_schema_from_hash(hash)
152+
properties = {}
153+
hash.each do |key, value|
154+
properties[key] = schema_for_value(value)
155+
end
156+
{ 'type' => 'object', 'properties' => properties }
157+
end
158+
159+
def self.db_type_to_openapi_type(db_type)
160+
case db_type
161+
when :string, :text
162+
'string'
163+
when :integer, :bigint
164+
'integer'
165+
when :boolean
166+
'boolean'
167+
when :datetime, :timestamp
168+
'string'
169+
when :float, :decimal
170+
'number'
171+
else
172+
'string'
173+
end
174+
end
175+
176+
def self.db_type_to_openapi_format(db_type)
177+
case db_type
178+
when :datetime, :timestamp
179+
'date-time'
180+
when :float
181+
'float'
182+
when :decimal
183+
'double'
184+
end
185+
end
186+
187+
def self.schema_for_value(value)
188+
case value
189+
when Hash
190+
generate_schema_from_hash(value)
191+
when Array
192+
items = value.empty? ? {} : schema_for_value(value.first)
193+
{ 'type' => 'array', 'items' => items }
194+
when String
195+
if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/)
196+
{ 'type' => 'string', 'format' => 'date-time' }
197+
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/)
198+
{ 'type' => 'string', 'format' => 'uuid' }
199+
else
200+
{ 'type' => 'string' }
201+
end
202+
when Integer
203+
{ 'type' => 'integer' }
204+
when TrueClass, FalseClass
205+
{ 'type' => 'boolean' }
206+
when NilClass
207+
{ 'type' => 'null' }
208+
else
209+
{ 'type' => 'string' }
210+
end
211+
end
212+
end

0 commit comments

Comments
 (0)