# frozen_string_literal: true require 'rspec/rails/api/utils' require 'active_support' module RSpec module Rails module Api # Class to render metadata. # Example: # ```rb # renderer = RSpec::Rails::Api::OpenApiRenderer.new # renderer.merge_context(example_context) # renderer.write_files # ``` class OpenApiRenderer # rubocop:disable Metrics/ClassLength attr_writer :api_servers, :api_title, :api_version, :api_description, :api_tos attr_reader :redactables def initialize @metadata = { resources: {}, entities: {} } @api_infos = {} @api_servers = [] @api_paths = {} @api_components = {} @api_tags = [] @api_contact = {} @api_license = {} @api_security = {} @redactables = {} end def redact_responses(pairs) @redactables = pairs end ## # Adds a security scheme definition to the API documentation # # @param reference [Symbol] Reference to use in the tests # @param name [String] Human friendly name # @param definition [Hash] Security scheme definition as per https://swagger.io/specification/#security-scheme-object def add_security_scheme(reference, name, definition) raise "Security scheme #{reference} is already defined" if @api_security.key? reference definition[:name] = name @api_security[reference] = definition end ## # Merges example context definition with the renderer data # # @param context [Hash] Metadata hash # @param dump_metadata [Boolean] Saves the raw metadata in `tmp/rra_metadata.yaml` for debugging # # @return [void def merge_context(context, dump_metadata: false) @metadata[:resources].deep_merge! context.respond_to?(:resources) ? context.resources : context[:resources] # Save context for debug and fixtures File.write ::Rails.root.join('tmp', 'rra_metadata.yaml'), @metadata.to_yaml if dump_metadata end ## # Write OpenAPI files # # @param path [String, nil] Where to save the files. Defaults to `/tmp/rspec_api_rails.*` when unset # @param only [[Symbol]] Formats to save the file to. Allowed values are `:yaml` and `:json` # # @return [void] def write_files(path = nil, only: %i[yaml json]) return unless write_file? RSpec.world.filtered_examples path ||= ::Rails.root.join('tmp', 'rspec_api_rails') metadata = prepare_metadata file_types = %i[yaml json] only.each do |type| next unless file_types.include? type data = metadata.to_yaml if type == :yaml data = JSON.pretty_generate(metadata) if type == :json File.write "#{path}.#{type}", data end end ## # Extracts metadata from context to generate an OpenAPI structure # # @return [Hash] The OpenAPI structure def prepare_metadata extract_metadata # Example: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore-expanded.yaml hash = { openapi: '3.0.0', info: @api_infos, servers: @api_servers, paths: @api_paths, components: @api_components, tags: @api_tags, } JSON.parse(hash.to_json) end ## # Sets the contact field # # @param name [String, nil] Contact name # @param email [String, nil] Contact Email # @param url [String, nil] Contact URL # # @return [void] def api_contact=(name: nil, email: nil, url: nil) @api_contact[:name] = name if name @api_contact[:email] = email if email @api_contact[:url] = url if url end ## # Sets the license field # # @param name [String, nil] License name # @param url [String, nil] License URL # # @return [void] def api_license=(name: nil, url: nil) @api_license[:name] = name if name @api_license[:url] = url if url end private def write_file?(examples) acceptance_examples = examples.values.flatten.filter do |e| e.metadata[:type] == :acceptance end unless acceptance_examples.none?(&:exception) puts "\n\e[00;31mSome acceptance tests failed. OpenApi specification file was not updated.\n\e[00m" return false end true end ## # Extracts metadata for rendering # # @return [void] def extract_metadata extract_security api_infos api_servers global_entities extract_from_resources end ## # Extracts metadata from security schemes for rendering # # @return [void] def extract_security return unless @api_security.keys.count.positive? @api_components['securitySchemes'] = @api_security end ## # Extracts metadata from resources for rendering # # @return [void] def extract_from_resources @api_components[:schemas] ||= {} @metadata[:resources].each do |resource_key, resource| @api_tags.push( name: resource_key.to_s, description: resource[:description].presence&.strip || '' ) process_resource resource: resource_key, resource_config: resource end end ## # Processes a resource from metadata # # @param resource [Symbol, nil] Resource name # @param resource_config [Hash, nil] Resource declaration # # # @return [void] def process_resource(resource: nil, resource_config: nil) # rubocop:disable Metrics/MethodLength http_verbs = %i[get post put patch delete] resource_config[:paths].each do |path_key, path| url = path_with_params path_key.to_s actions = {} parameters = path.key?(:path_params) ? process_path_params(path[:path_params]) : [] path[:actions].each_key do |action| next unless http_verbs.include? action actions[action] = process_action resource: resource, path: path_key, path_config: path, action_config: action, parameters: parameters end @api_paths[url] = actions end end ## # Processes path parameters for rendering # # @param params [Hash] Path parameters def process_path_params(params) parameters = [] params.each do |name, param| parameters.push process_path_param name, param end parameters end def process_entity(entity) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity schema = { properties: {}, } required = [] entity.fields.each do |name, field| property = { description: field.description.presence&.strip || '', type: PARAM_TYPES[field.type][:type], } property[:format] = PARAM_TYPES[field.type][:format] if PARAM_TYPES[field.type][:format] if PRIMITIVES.include? field.attributes property[:items] = { type: field.attributes } elsif field.type == :object && field.attributes.is_a?(Symbol) property = { '$ref' => "#/components/schemas/#{field.attributes}" } elsif field.type == :array && field.attributes.is_a?(Symbol) property = { type: :array, items: { '$ref' => "#/components/schemas/#{field.attributes}" } } end required.push name unless field.required == false schema[:properties][name] = property end schema[:required] = required unless required.size.zero? schema end ## # Processes a path parameter from metadata # # @param name [Symbol, nil] Parameter name # @param param [Hash, nil] Parameter declaration # # # @return [void] def process_path_param(name, param) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize parameter = { name: name.to_s, description: param[:description].presence&.strip || '', required: param[:required] || true, in: param[:scope].to_s, schema: { type: PARAM_TYPES[param[:type]][:type], }, } parameter[:schema][:format] = PARAM_TYPES[param[:type]][:format] if PARAM_TYPES[param[:type]][:format] parameter end ## # Processes an action from metadata # # @param resource [Symbol, nil] Target resource # @param path [Symbol, nil] Target path # @param path_config [Hash, nil] Path configuraton # @param action_config [Symbol, nil] Target action # @param parameters [Array, nil] Path parameters # # @return [void] # # FIXME: Rename "action_config" to "action" # FIXME: Rename "parameters" to "path_parameters" # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def process_action(resource: nil, path: nil, path_config: nil, action_config: nil, parameters: nil) responses = {} request_body = nil if %i[post put patch].include?(action_config) && path_config[:actions][action_config][:params].keys.count.positive? schema = path_config[:actions][action_config][:params] schema_ref = escape_operation_id("#{action_config}_#{path}") examples = process_examples(path_config[:actions][action_config][:statuses]) request_body = process_request_body schema: schema, ref: schema_ref, examples: examples end path_config[:actions][action_config][:statuses].each do |status_key, status| content = status[:example][:response] responses[status_key] = process_response status: status_key, status_config: status, content: content end action = { summary: path_config[:actions][action_config][:summary]&.strip || '', description: path_config[:actions][action_config][:description].presence&.strip || '', operationId: "#{resource} #{action_config} #{path}".downcase.gsub(/[^\w]/, '_'), parameters: parameters, responses: responses, tags: [resource.to_s], } if path_config[:actions][action_config].key? :security references = path_config[:actions][action_config][:security] action[:security] = [] references.each do |reference| raise "No security scheme defined with reference #{reference}" unless @api_security.key? reference action[:security].push({ reference => [] }) end end action[:requestBody] = request_body if request_body action end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity ## # Processes a request body from metadata # # @param schema [Hash] Schema # @param ref [String] Reference # @param examples [Hash] Example # # @return [void] def process_request_body(schema: nil, ref: nil, examples: {}) Utils.deep_set @api_components, ['schemas', ref], schema { # description: '', required: true, content: { content_type_from_schema(schema) => { schema: { '$ref' => "#/components/schemas/#{ref}" }, examples: examples, }, }, } end def content_type_from_schema(schema) schema_includes_file?(schema) ? 'multipart/form-data' : 'application/json' end def schema_includes_file?(schema) return true if schema[:type] == 'string' && schema[:format] == 'binary' return false unless schema[:properties].is_a?(Hash) && schema[:required].is_a?(Array) schema[:properties].each_value do |definition| next unless schema_includes_file?(definition) return true end false end ## # Process a response from metadata # # @param status [Symbol] Status code # @param status_config [Hash] Configuration for status code # @param content [String] Response content # # @return [void] def process_response(status: nil, status_config: nil, content: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity response = { description: status_config[:description].presence&.strip || '' } return response if status.to_s == '204' && content # No content data = begin JSON.parse(content) rescue JSON::ParserError, TypeError content end entity = status_config[:expectations][:one] || status_config[:expectations][:many] # TODO: handle sub-entities if @redactables.key?(entity) && data.is_a?(Hash) if status_config[:expectations][:one] @redactables[entity].each_pair do |attribute, replacement| data[attribute.to_s] = replacement end else data.each_index do |index| @redactables[entity].each_pair do |attribute, replacement| data[index][attribute.to_s] = replacement end end end end response[:content] = { 'application/json': { schema: response_schema(status_config[:expectations]), examples: { default: { value: data } }, }, } response end def response_schema(expectations) # rubocop:disable Metrics/MethodLength if expectations[:many] items = if PRIMITIVES.include?(expectations[:many]) { type: expectations[:many] } else { '$ref' => "#/components/schemas/#{expectations[:many]}" } end { type: 'array', items: items } elsif expectations[:one] if PRIMITIVES.include?(expectations[:one]) { type: expectations[:one] } else { '$ref' => "#/components/schemas/#{expectations[:one]}" } end end end ## # Processes examples from statuses # # @param statuses [Hash] # # @return [Hash] Request examples def process_examples(statuses) request_examples = {} statuses.each do |code, request| request_examples[code] = { summary: "Example for a #{code} code", value: request[:example][:params], } end request_examples end def global_entities return if RSpec::Rails::Api::Metadata.entities.keys.count.zero? @api_components[:schemas] = {} RSpec::Rails::Api::Metadata.entities.each_pair do |name, entity| @api_components[:schemas][name] = process_entity(entity) end end ## # Converts path with params like ":id" to their OpenAPI representation # # @param string [String] The original path # # @return [String] OpenAPI path representation def path_with_params(string) string.gsub(/(?::(\w*))/) do |e| "{#{e.sub(':', '')}}" end end ## # Converts a string to a snake_cased string to use as operationId # # @param string [String] Original string # # @return [String] Snake_cased string def escape_operation_id(string) string.downcase.gsub(/[^\w]+/, '_') end ## # Fills the API general information sections # # @return [void] def api_infos # rubocop:disable Metrics/CyclomaticComplexity @api_infos = { title: @api_title || 'Some sample app', version: @api_version || '1.0', } @api_infos[:description] = @api_description.strip || '' if @api_description.present? @api_infos[:termsOfService] = @api_tos if @api_tos @api_infos[:contact] = @api_contact if @api_contact[:name] @api_infos[:license] = @api_license if @api_license[:name] @api_infos end ## # Fills the API servers section # # @return [void] def api_servers @api_servers || [ { url: 'http://api.example.com' }, ] end end end end end