gregschmit/rails-rest-framework

View on GitHub
lib/rest_framework/utils.rb

Summary

Maintainability
A
0 mins
Test Coverage
module RESTFramework::Utils
  HTTP_VERB_ORDERING = %w(GET POST PUT PATCH DELETE OPTIONS HEAD)

  # Convert `extra_actions` hash to a consistent format: `{path:, methods:, kwargs:}`, and
  # additional metadata fields.
  #
  # If a controller is provided, labels will be added to any metadata fields.
  def self.parse_extra_actions(extra_actions, controller: nil)
    return (extra_actions || {}).map { |k, v|
      path = k
      metadata = {}

      # Convert structure to path/methods/kwargs.
      if v.is_a?(Hash)  # Allow kwargs to be used to define path differently from the key.
        # Symbolize keys (which also makes a copy so we don't mutate the original).
        v = v.symbolize_keys
        methods = v.delete(:methods)
        if v.key?(:method)
          methods = v.delete(:method)
        end

        # First, remove the route metadata.
        metadata = v.delete(:metadata) || {}

        # Add label to fields.
        if controller && metadata[:fields]
          metadata[:fields] = metadata[:fields].map { |f|
            [f, {}]
          }.to_h if metadata[:fields].is_a?(Array)
          metadata[:fields]&.each do |field, cfg|
            cfg[:label] = controller.get_label(field) unless cfg[:label]
          end
        end

        # Override path if it's provided.
        if v.key?(:path)
          path = v.delete(:path)
        end

        # Pass any further kwargs to the underlying Rails interface.
        kwargs = v.presence
      elsif v.is_a?(Array) && v.length == 1
        methods = v[0]
      else
        methods = v
      end

      # Insert action label if it's not provided.
      if controller
        metadata[:label] ||= controller.get_label(k)
      end

      next [
        k,
        {
          path: path,
          methods: methods,
          kwargs: kwargs,
          type: :extra,
          metadata: metadata.presence,
        }.compact,
      ]
    }.to_h
  end

  # Get actions which should be skipped for a given controller.
  def self.get_skipped_builtin_actions(controller_class)
    return (
      RESTFramework::BUILTIN_ACTIONS.keys + RESTFramework::BUILTIN_MEMBER_ACTIONS.keys
    ).reject do |action|
      controller_class.method_defined?(action)
    end
  end

  # Get the first route pattern which matches the given request.
  def self.get_request_route(application_routes, request)
    application_routes.router.recognize(request) { |route, _| return route }
  end

  # Normalize a path pattern by replacing URL params with generic placeholder, and removing the
  # `(.:format)` at the end.
  def self.comparable_path(path)
    return path.gsub("(.:format)", "").gsub(/:[0-9A-Za-z_-]+/, ":x")
  end

  # Show routes under a controller action; used for the browsable API.
  def self.get_routes(application_routes, request, current_route: nil)
    current_route ||= self.get_request_route(application_routes, request)
    current_path = current_route.path.spec.to_s.gsub("(.:format)", "")
    current_path = "" if current_path == "/"
    current_levels = current_path.count("/")
    current_comparable_path = %r{^#{Regexp.quote(self.comparable_path(current_path))}(/|$)}

    # Add helpful properties of the current route.
    path_args = current_route.required_parts.map { |n| request.path_parameters[n] }
    route_props = {
      with_path_args: ->(r) {
        r.format(r.required_parts.each_with_index.map { |p, i| [p, path_args[i]] }.to_h)
      },
    }

    # Return routes that match our current route subdomain/pattern, grouped by controller. We
    # precompute certain properties of the route for performance.
    return route_props, application_routes.routes.select { |r|
      # We `select` first to avoid unnecessarily calculating metadata for routes we don't even want
      # to show.
      (r.defaults[:subdomain].blank? || r.defaults[:subdomain] == request.subdomain) &&
        current_comparable_path.match?(self.comparable_path(r.path.spec.to_s)) &&
        r.defaults[:controller].present? &&
        r.defaults[:action].present?
    }.map { |r|
      path = r.path.spec.to_s.gsub("(.:format)", "")
      levels = path.count("/")
      matches_path = current_path == path
      matches_params = r.required_parts.length == current_route.required_parts.length

      {
        route: r,
        verb: r.verb,
        path: path,
        # Starts at the number of levels in current path, and removes the `(.:format)` at the end.
        relative_path: path.split("/")[current_levels..]&.join("/").presence || "/",
        controller: r.defaults[:controller].presence,
        action: r.defaults[:action].presence,
        matches_path: matches_path,
        matches_params: matches_params,
        # The following options are only used in subsequent processing in this method.
        _levels: levels,
      }
    }.sort_by { |r|
      [
        # Sort by levels first, so routes matching closely with current request show first.
        r[:_levels],
        # Then match by path, but manually sort ':' to the end using knowledge that Ruby sorts the
        # pipe character '|' after alphanumerics.
        r[:path].tr(":", "|"),
        # Finally, match by HTTP verb.
        HTTP_VERB_ORDERING.index(r[:verb]) || 99,
      ]
    }.group_by { |r| r[:controller] }.sort_by { |c, _r|
      # Sort the controller groups by current controller first, then alphanumerically.
      [request.params[:controller] == c ? 0 : 1, c]
    }.to_h
  end

  # Custom inflector for RESTful controllers.
  def self.inflect(s, acronyms=nil)
    acronyms&.each do |acronym|
      s = s.gsub(/\b#{acronym}\b/i, acronym)
    end

    return s
  end

  # Parse fields hashes.
  def self.parse_fields_hash(fields_hash, model, exclude_associations: nil)
    parsed_fields = fields_hash[:only] || (
      model ? self.fields_for(model, exclude_associations: exclude_associations) : []
    )
    parsed_fields += fields_hash[:include].map(&:to_s) if fields_hash[:include]
    parsed_fields -= fields_hash[:exclude].map(&:to_s) if fields_hash[:exclude]

    # Warn for any unknown keys.
    (fields_hash.keys - [:only, :include, :exclude]).each do |k|
      Rails.logger.warn("RRF: Unknown key in fields hash: #{k}")
    end

    # We should always return strings, not symbols.
    return parsed_fields.map(&:to_s)
  end

  # Get the fields for a given model, including not just columns (which includes
  # foreign keys), but also associations. Note that we always return an array of
  # strings, not symbols.
  def self.fields_for(model, exclude_associations: nil)
    foreign_keys = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
    base_fields = model.column_names.reject { |c| c.in?(foreign_keys) }

    return base_fields if exclude_associations

    # Add associations in addition to normal columns.
    return base_fields + model.reflections.map { |association, ref|
      # Ignore associations for which we have custom integrations.
      if ref.class_name.in?(%w(ActiveStorage::Attachment ActiveStorage::Blob ActionText::RichText))
        next nil
      end

      if ref.collection? && RESTFramework.config.large_reverse_association_tables&.include?(
        ref.table_name,
      )
        next nil
      end

      next association
    }.compact + model.reflect_on_all_associations(:has_one).collect(&:name).select { |n|
      n.to_s.start_with?("rich_text_")
    }.map { |n|
      n.to_s.delete_prefix("rich_text_")
    } + model.attachment_reflections.keys
  end

  # Get the sub-fields that may be serialized and filtered/ordered for a reflection.
  def self.sub_fields_for(ref)
    if !ref.polymorphic? && model = ref.klass
      sub_fields = [model.primary_key].flatten.compact
      label_fields = RESTFramework.config.label_fields

      # Preferrably find a database column to use as label.
      if match = label_fields.find { |f| f.in?(model.column_names) }
        return sub_fields + [match]
      end

      # Otherwise, find a method.
      if match = label_fields.find { |f| model.method_defined?(f) }
        return sub_fields + [match]
      end

      return sub_fields
    end

    return ["id", "name"]
  end

  # Get a field's id/ids variation.
  def self.get_id_field(field, reflection)
    if reflection.collection?
      return "#{field.singularize}_ids"
    elsif reflection.belongs_to?
      # The id field for belongs_to is always the foreign key column name, even if the
      # association is named differently.
      return reflection.foreign_key
    end

    return nil
  end
end