043. Upgrade Swagger 2.0 lên 3.0 cho Grape API
Written by Thach on
Upgrade Swagger 2.0 lên Swagger 3.0.
1. Setup Gems
Vì cái gem grape-swagger-rails đã không còn được hỗ trợ lâu rồi, và dừng lại ở swagger 2.0, nên là mình sẽ chuyển qua dùng rswag.
# Gemfile
gem 'rswag-api', '3.0.0.pre'
gem 'rswag-ui', '3.0.0.pre'
2. Initializers
Vì không còn dùng grape-swagger-rails nữa, nên chúng ta cũng cần phải sửa lại file
initializers/swagger.rb.
# config/initializers/swagger.rb
require_relative '../../lib/swagger_to_openapi_converter'
Rails.application.config.middleware.use SwaggerToOpenapiConverter
File routes.rb cũng đổi luôn.
# config/routes.rb
Rails.application.routes.draw do
mount Rswag::Ui::Engine => '/documentation'
mount API::V1::Base => '/'
end
# config/initializers/rswag_api.rb
Rswag::Api.configure do |c|
c.openapi_root = Rails.root.to_s
end
# config/initializers/rswag_ui.rb
Rswag::Ui.configure do |c|
c.openapi_endpoint '/api/v1/swagger_doc.json', 'Sample API v1'
end
3. Convert from 2.0 to 3.0
Gem grape-swagger chỉ còn hỗ trợ đến version 2.0, nên chúng ta cần phải chuyển đổi sang version 3.0 một cách thủ công. Phần này là code của AI, nên là mình cũng để ở đây vậy thôi, chứ không dám giải thích cặn kẽ. Miễn nó chạy là được.
# lib/swagger_to_openapi_converter.rb
# Middleware to convert Swagger 2.0 to OpenAPI 3.0 specification
class SwaggerToOpenapiConverter
def initialize(app)
@app = app
end
def call(env)
status, headers, response = @app.call(env)
# Only intercept swagger JSON responses
if env['PATH_INFO'] == '/api/v1/swagger_doc.json' || env['PATH_INFO'] == '/api/v1/swagger_doc'
begin
body = []
response.each { |part| body << part }
swagger_json = JSON.parse(body.join)
# Convert Swagger 2.0 to OpenAPI 3.0
openapi_json = convert_swagger_to_openapi(swagger_json)
response_body = JSON.pretty_generate(openapi_json)
headers['Content-Length'] = response_body.bytesize.to_s
[status, headers, [response_body]]
rescue StandardError => e
Rails.logger.error("Failed to convert Swagger to OpenAPI: #{e.message}")
[status, headers, response]
end
else
[status, headers, response]
end
end
private
def convert_swagger_to_openapi(swagger)
openapi = {
openapi: '3.0.3',
info: swagger['info'] || {},
servers: build_servers(swagger),
paths: convert_paths(swagger['paths'] || {}),
components: build_components(swagger)
}
# Add tags if present
openapi[:tags] = swagger['tags'] if swagger['tags']
# Add externalDocs if present
openapi[:externalDocs] = swagger['externalDocs'] if swagger['externalDocs']
# Add global security requirement for Bearer token
# This makes Swagger UI automatically include the token in requests
openapi[:security] = [{ BearerAuth: [] }]
openapi
end
def build_servers(swagger)
servers = []
base_path = swagger['basePath'] || ''
# Use relative path to work with both HTTP and HTTPS
# This allows the Swagger UI to work regardless of the protocol
servers << { url: base_path.present? ? base_path : '/' }
servers
end
def convert_paths(paths)
converted_paths = {}
paths.each do |path, path_item|
converted_paths[path] = convert_path_item(path_item)
end
converted_paths
end
def convert_path_item(path_item)
converted_item = {}
path_item.each do |method, operation|
next if method == 'parameters' # Handle separately
if %w[get post put patch delete head options].include?(method)
converted_item[method] = convert_operation(operation, path_item['parameters'])
else
converted_item[method] = operation
end
end
converted_item
end
def convert_operation(operation, path_parameters = [])
converted = operation.dup
# Convert parameters
if operation['parameters'] || path_parameters.present?
all_params = (path_parameters || []) + (operation['parameters'] || [])
converted['parameters'] = []
request_body_params = []
all_params.each do |param|
# Skip Authorization header parameters - they'll be handled by security schemes
next if param['in'] == 'header' && param['name']&.downcase == 'authorization'
if param['in'] == 'body'
request_body_params << param
elsif param['in'] == 'formData'
request_body_params << param
else
converted['parameters'] << convert_parameter(param)
end
end
# Convert body/formData parameters to requestBody
if request_body_params.any?
converted['requestBody'] = build_request_body(request_body_params, operation['consumes'])
end
converted.delete('parameters') if converted['parameters'].empty?
end
# Convert responses
if operation['responses']
converted['responses'] = convert_responses(operation['responses'], operation['produces'])
end
# Remove Swagger 2.0 specific fields
converted.delete('consumes')
converted.delete('produces')
converted.delete('schemes')
converted
end
def convert_parameter(param)
converted = param.dup
# Handle schema for parameters
if param['type'] && !param['schema']
schema = { type: param['type'] }
schema[:format] = param['format'] if param['format']
schema[:enum] = param['enum'] if param['enum']
schema[:default] = param['default'] if param['default']
schema[:minimum] = param['minimum'] if param['minimum']
schema[:maximum] = param['maximum'] if param['maximum']
schema[:items] = param['items'] if param['items']
converted['schema'] = schema
converted.delete('type')
converted.delete('format')
converted.delete('enum')
converted.delete('default')
converted.delete('minimum')
converted.delete('maximum')
converted.delete('items')
end
converted
end
def build_request_body(params, consumes = nil)
request_body = { required: false }
content = {}
media_types = consumes || ['application/json']
# Handle body parameters
body_param = params.find { |p| p['in'] == 'body' }
if body_param
request_body[:required] = body_param['required'] || false
request_body[:description] = body_param['description'] if body_param['description']
media_types.each do |media_type|
content[media_type] = { schema: body_param['schema'] || {} }
end
else
# Handle formData parameters
form_params = params.select { |p| p['in'] == 'formData' }
if form_params.any?
properties = {}
required_fields = []
form_params.each do |param|
properties[param['name']] = build_schema_from_param(param)
required_fields << param['name'] if param['required']
end
schema = { type: 'object', properties: properties }
schema[:required] = required_fields if required_fields.any?
media_types.each do |media_type|
# Use multipart/form-data for file uploads, otherwise use the specified type
actual_media_type = properties.values.any? { |s| s[:type] == 'string' && s[:format] == 'binary' } ? 'multipart/form-data' : media_type
content[actual_media_type] = { schema: schema }
end
end
end
request_body[:content] = content
request_body
end
def build_schema_from_param(param)
schema = { type: param['type'] || 'string' }
schema[:format] = param['format'] if param['format']
schema[:description] = param['description'] if param['description']
schema[:enum] = param['enum'] if param['enum']
schema[:default] = param['default'] if param['default']
schema[:items] = param['items'] if param['items']
schema
end
def convert_responses(responses, produces = nil)
converted = {}
responses.each do |status_code, response|
converted[status_code] = convert_response(response, produces)
end
converted
end
def convert_response(response, produces = nil)
converted = { description: response['description'] || '' }
if response['schema']
content = {}
media_types = produces || ['application/json']
# Clean and validate the schema
schema = clean_schema(response['schema'])
media_types.each do |media_type|
content[media_type] = { schema: schema }
end
converted[:content] = content
end
converted[:headers] = response['headers'] if response['headers']
converted
end
def build_components(swagger)
components = {}
# Convert definitions to schemas and clean invalid references
if swagger['definitions']
components[:schemas] = clean_definitions(swagger['definitions'])
end
# Always add Bearer authentication security scheme
# This enables the "Authorize" button in Swagger UI
components[:securitySchemes] = {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Enter your Bearer token in the format: your-token-here (without "Bearer" prefix)'
}
}
# Convert securityDefinitions to securitySchemes if they exist
if swagger['securityDefinitions']
components[:securitySchemes].merge!(convert_security_definitions(swagger['securityDefinitions']))
end
# Add responses if present
components[:responses] = swagger['responses'] if swagger['responses']
# Add parameters if present
if swagger['parameters']
components[:parameters] = swagger['parameters'].transform_values { |param| convert_parameter(param) }
end
components
end
def convert_security_definitions(security_definitions)
converted = {}
security_definitions.each do |name, definition|
converted[name] = convert_security_definition(definition)
end
converted
end
def convert_security_definition(definition)
converted = { type: definition['type'] }
case definition['type']
when 'apiKey'
converted[:name] = definition['name']
converted[:in] = definition['in']
when 'oauth2'
converted[:flows] = convert_oauth2_flows(definition)
when 'basic'
converted[:scheme] = 'basic'
end
converted[:description] = definition['description'] if definition['description']
converted
end
def convert_oauth2_flows(definition)
flows = {}
case definition['flow']
when 'implicit'
flows[:implicit] = {
authorizationUrl: definition['authorizationUrl'],
scopes: definition['scopes'] || {}
}
when 'password'
flows[:password] = {
tokenUrl: definition['tokenUrl'],
scopes: definition['scopes'] || {}
}
when 'application'
flows[:clientCredentials] = {
tokenUrl: definition['tokenUrl'],
scopes: definition['scopes'] || {}
}
when 'accessCode'
flows[:authorizationCode] = {
authorizationUrl: definition['authorizationUrl'],
tokenUrl: definition['tokenUrl'],
scopes: definition['scopes'] || {}
}
end
flows
end
def clean_definitions(definitions)
return {} unless definitions.is_a?(Hash)
cleaned = {}
definitions.each do |name, schema|
# Skip invalid definition names (Ruby object inspections)
if name.to_s.include?('#<Class:') || name.to_s.include?('#<Module:')
Rails.logger.warn("Skipping invalid definition name: #{name}")
next
end
cleaned[name] = clean_schema(schema)
end
cleaned
end
def clean_schema(schema)
return schema unless schema.is_a?(Hash)
cleaned = {}
schema.each do |key, value|
if key == '$ref'
# Fix invalid references like "#/definitions/#<Class:0x...>"
ref_value = value.to_s
if ref_value.include?('#<Class:') || ref_value.include?('#<Module:')
# Invalid reference - replace with a generic object schema
Rails.logger.warn("Invalid schema reference detected: #{ref_value}. Replacing with generic object.")
return { type: 'object', description: 'Response object' }
elsif ref_value.start_with?('#/definitions/')
# Convert Swagger 2.0 definitions to OpenAPI 3.0 schemas
cleaned[key] = ref_value.sub('#/definitions/', '#/components/schemas/')
else
cleaned[key] = ref_value
end
elsif value.is_a?(Hash)
cleaned[key] = clean_schema(value)
elsif value.is_a?(Array)
cleaned[key] = value.map { |item| item.is_a?(Hash) ? clean_schema(item) : item }
else
cleaned[key] = value
end
end
cleaned
end
end
4. Setup basic authen for swagger
# config/initializers/documentation_basic_auth.rb
require_relative '../../app/middleware/documentation_basic_auth'
# Protect the documentation endpoint with HTTP Basic Authentication
Rails.application.config.middleware.use DocumentationBasicAuth
# app/middleware/documentation_basic_auth.rb
# Middleware to protect the Swagger documentation endpoint with HTTP Basic Authentication
class DocumentationBasicAuth
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
# Only protect /documentation paths
if request.path.start_with?('/documentation')
auth = Rack::Auth::Basic::Request.new(env)
if !auth.provided?
return unauthorized_response
elsif !auth.basic?
return bad_request_response
elsif !valid_credentials?(auth.credentials)
return unauthorized_response
end
end
@app.call(env)
end
private
def valid_credentials?(credentials)
username, password = credentials
expected_username = ENV['BASIC_AUTHEN_USER']
expected_password = ENV['BASIC_AUTHEN_PASSWORD']
# If env vars are not set, deny access
return false if expected_username.blank? || expected_password.blank?
# Compare credentials
Rack::Utils.secure_compare(username.to_s, expected_username) &&
Rack::Utils.secure_compare(password.to_s, expected_password)
end
def unauthorized_response
[
401,
{
'Content-Type' => 'application/json',
'WWW-Authenticate' => 'Basic realm="API Documentation"'
},
[JSON.generate({
error: 'Unauthorized',
message: 'HTTP Basic Authentication required to access API documentation.',
hint: 'Please provide valid credentials configured in BASIC_AUTHEN_USER and BASIC_AUTHEN_PASSWORD environment variables.'
})]
]
end
def bad_request_response
[
400,
{ 'Content-Type' => 'application/json' },
[JSON.generate({
error: 'Bad Request',
message: 'Only Basic Authentication is supported.'
})]
]
end
end
Comments