# frozen_string_literal: true require 'rspec/rails/api/utils' require 'rspec/rails/api/validator' require 'rspec/rails/api/open_api_renderer' require 'rspec/rails/api/entity_config' module RSpec module Rails module Api # Handles contexts and examples metadata. class Metadata # rubocop:disable Metrics/ClassLength attr_reader :resources, :parameters, :current_resource, :current_url, :current_method, :current_code def initialize @resources = {} @parameters = {} # Only used when building metadata during RSpec boot @current_resource = nil @current_method = nil @current_url = nil @current_code = nil end class << self ## # Define an entity globally. # # Global entities will be available within the specs, but if they are re-declared locally, the local variant # will be used. # # @param name [Symbol] Entity name # @param fields [Hash] Fields definitions # # @return [void] def add_entity(name, fields) @entities ||= {} raise "#{name} is already declared" if @entities.key? name @entities[name] = EntityConfig.new fields end def entities @entities || {} end def reset @entities = {} end end ## # Adds a resource to metadata # # @param name [String] Resource name # @param description [String] Resource description # # @return [void] def add_resource(name, description) @resources[name.to_sym] ||= { description: description, paths: {} } @current_resource = name.to_sym end ## # Adds a parameter definition # # @param name [Symbol] Parameter definition name # @param fields [Hash] Fields definitions # # @return [void] def add_parameter(name, fields) raise "Parameter #{name} is already defined" if @parameters[name] fields.each_value do |field| field[:required] = field[:required] != false field[:schema] = { type: field[:of] } if field[:type] == :array && PRIMITIVES.include?(field[:of]) end @parameters[name] = fields end ## # Adds path parameters definition # # @param fields [Hash] Parameters definitions # # @return [void] def add_path_params(fields) # rubocop:disable Metrics/MethodLength check_current_context :resource, :url chunks = @current_url.split('?') fields.each do |name, field| valid_attribute = Validator.valid_type?(field[:type], except: %i[array object]) raise "Field type not allowed: #{field[:type]}" unless valid_attribute scope = path_param_scope(chunks, name) Utils.deep_set(@resources, [@current_resource, 'paths', @current_url, 'path_params', name], description: field[:description] || nil, type: field[:type] || nil, required: field[:required] != false, scope: scope) end end ## # Add request parameters (_body_) # # Fields should be something like: # id: {type: :number, description: 'Something'}, # name: {type: string, description: 'Something'} # Ex. with sub elements: # id: {type: :number, description: 'Something'}, # something: {type: :object, description: 'Something', properties: { # property: {type: :string, description: 'Something'}, # ... # }} # # @param fields [Hash] Parameters definitions # # @return [void] def add_request_params(fields) check_current_context :resource, :url, :method params = organize_params fields Utils.deep_set(@resources, [@current_resource, 'paths', @current_url, 'actions', @current_method, 'params'], params) end # Associate a defined security scheme to this request # # @param references [Array] Security scheme reference def add_security_references(*references) check_current_context :resource, :url, :method refs = @resources.dig @current_resource, 'paths', @current_url, 'actions', @current_method, 'security' refs ||= [] refs += references Utils.deep_set(@resources, [@current_resource, 'paths', @current_url, 'actions', @current_method, 'security'], refs) end ## # Adds an action and sets `@current_url` and `@current_method` # # @param method [:get, :post, :put, :patch, delete] Method name # @param url [String] Associated URL # @param summary [String] What the route does for given method # @param description [String] Longer description of this action # # @return [void] def add_action(method, url, summary, description = '') check_current_context :resource Utils.deep_set(@resources, [@current_resource, 'paths', url, 'actions', method], description: description || '', summary: summary, statuses: {}, params: {}) @current_url = url @current_method = method end ## # Adds a status code to metadata and sets `@current_code` # # @param status_code [Integer] The status code # @param description [String] Code description # # @return [void] # # rubocop:disable Layout/LineLength def add_status_code(status_code, description) check_current_context :resource, :url, :method Utils.deep_set(@resources, [@current_resource, 'paths', @current_url, 'actions', @current_method, 'statuses', status_code], description: description, example: { response: nil }) @current_code = status_code end # rubocop:enable Layout/LineLength ## # Gets the current example # # @return [Hash] Current example metadata def current_example @resources.dig @current_resource, :paths, @current_url.to_sym, :actions, @current_method.to_sym, :statuses, @current_code.to_s.to_sym end ## # Adds expectations for current example # # @param one [Hash, nil] Entity definition # @param many [Hash, nil] Entity definition # # @return [void] def add_expectations(one, many) check_current_context :resource, :url, :method, :code none = !many && !one # rubocop:disable Layout/LineLength Utils.deep_set(@resources, [@current_resource, 'paths', @current_url, 'actions', @current_method, 'statuses', @current_code, 'expectations'], { one: one, many: many, none: none, }) # rubocop:enable Layout/LineLength end ## # Adds a request example # # @param url [String, nil] Visited URL # @param action [String, nil] HTTP verb # @param status_code [Integer, nil] Status code # @param response [String, nil] Response body # @param path_params [Hash, nil] Used path parameters # @param params [Hash, nil] Used body parameters # # rubocop:disable Metrics/ParameterLists def add_request_example(url: nil, action: nil, status_code: nil, response: nil, path_params: nil, params: nil) resource = nil @resources.each do |key, res| resource = key if res.dig :paths, url.to_sym, :actions, action.to_sym, :statuses, status_code.to_s.to_sym end raise "Resource not found for #{action.upcase} #{url}" unless resource Utils.deep_set(@resources, [resource, 'paths', url, 'actions', action, 'statuses', status_code, 'example'], path_params: path_params, params: params, response: response) end # rubocop:enable Metrics/ParameterLists ## # @return [Hash] Hash representation of the metadata def to_h { resources: @resources, entities: @entities, } end private ## # Checks for the definition of given scopes. # This is useful to verify if all metadata is set for the current example # # @param scope [Symbol[]] List of scope to check for # # @return [Boolean] def check_current_context(*scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity scope ||= [] raise 'No resource declared' if scope.include?(:resource) && !@current_resource raise 'No action declared' if scope.include?(:method) && !@current_method raise 'No url declared' if scope.include?(:url) && !@current_url raise 'No status code declared' if scope.include?(:code) && !@current_code end ## # Checks if a given parameter is used in the URL (_path_) or querystring (query) # # @param url_chunks [String[]] Chunks of an url splitted on the query separator (`?`) # @param name [Symbol] Name of the parameter # # @return [:path, :query] def path_param_scope(url_chunks, name) if /:#{name}/.match?(url_chunks[0]) :path else :query end end ## # Checks and complete a field definition # # @param fields [Hash,Symbol] Fields definitions # # @return [Hash,Symbol] Completed field definition def organize_params(fields) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity return fields if fields.is_a?(Symbol) && PRIMITIVES.include?(fields) raise "Unsupported type \"#{fields}\"" unless fields.is_a? Hash out = { properties: {} } required = [] allowed_types = %i[array object] fields.each do |name, field| allowed_type = allowed_types.include?(field[:type]) || PARAM_TYPES.key?(field[:type]) raise "Field type not allowed: #{field[:type]}" unless allowed_type required.push name.to_s if field[:required] != false out[:properties][name] = fill_request_param field end out[:required] = required if required.count.positive? out end ## # Checks and completes a request parameter definition # # @param field [Hash] Parameter definition # # @return [Hash] Completed parameter def fill_request_param(field) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize if field[:type] == :object && field[:attributes] organize_params field[:attributes] else properties = { type: PARAM_TYPES[field[:type]][:type], description: field[:description] || nil, } properties[:format] = PARAM_TYPES[field[:type]][:format] if PARAM_TYPES[field[:type]][:format] properties[:items] = organize_params field[:of] if field[:type] == :array && field[:of] properties end end end end end end