043. Upgrade Swagger 2.0 lên 3.0 cho Grape API

Thach
Written by Thach on
043. Upgrade Swagger 2.0 lên 3.0 cho Grape API

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

comments powered by Disqus