• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gregschmit / rails-rest-framework / 30583007308

30 Jul 2026 09:20PM UTC coverage: 94.964% (+3.0%) from 91.975%
30583007308

Pull #40

github

gregschmit
Fail loudly when delegating to a non-public method.

Delegated actions now raise DelegatedMethodError instead of returning
404 when the target method is missing or non-public, so developer
misconfiguration surfaces rather than masquerading as "not found".

Add OpenAPI tests pinning that inclusion-validator options (array,
symbol, Proc/lambda) stay JSON-safe in x-rrf-validators.
Pull Request #40: v2

321 of 327 new or added lines in 12 files covered. (98.17%)

1 existing line in 1 file now uncovered.

1301 of 1370 relevant lines covered (94.96%)

475.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.19
/lib/rest_framework/utils.rb
1
module RESTFramework::Utils
2✔
2
  HTTP_VERB_ORDERING = %w[GET POST PUT PATCH DELETE OPTIONS HEAD]
2✔
3

4
  # Get the first route pattern which matches the given request.
5
  def self.get_request_route(application_routes, request)
2✔
6
    # Prefer the route already resolved by the router to avoid an expensive `recognize` call. This
7
    # is also required for Rails 8.1+ where OPTIONS routes are non-anchored, causing `path_info` to
8
    # be modified during dispatch, which makes `recognize` fail from inside the controller action.
9
    if route = request.env["action_dispatch.route"]
60✔
10
      return route
20✔
11
    end
12

13
    application_routes.router.recognize(request) { |route, _| return route }
80✔
14
  end
15

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

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

30
    # Get current route path parameters.
31
    path_params = current_route.required_parts.map { |n| request.path_parameters[n] }
82✔
32

33
    # Return routes that match our current route subdomain/pattern, grouped by controller. We
34
    # precompute certain properties of the route for performance.
35
    application_routes.routes.select { |r|
60✔
36
      # We `select` first to avoid unnecessarily calculating metadata for routes we don't even want
37
      # to show.
38
      (r.defaults[:subdomain].blank? || r.defaults[:subdomain] == request.subdomain) &&
18,180✔
39
          current_comparable_path.match?(self.comparable_path(r.path.spec.to_s)) &&
40
          r.defaults[:controller].present? &&
41
          r.defaults[:action].present?
42
    }.map { |r|
43
      path = r.path.spec.to_s.gsub("(.:format)", "")
2,724✔
44

45
      # Starts at the number of levels in current path, and removes the `(.:format)` at the end.
46
      relative_path = path.split("/")[current_levels..]&.join("/").presence || "/"
2,724✔
47

48
      # This path is what would need to be concatenated onto the current path to get to the
49
      # destination path.
50
      concat_path = relative_path.gsub(/^[^\/]*/, "").presence || "/"
2,724✔
51

52
      levels = path.count("/")
2,724✔
53
      matches_path = current_path == path
2,724✔
54
      matches_params = r.required_parts.length == current_route.required_parts.length
2,724✔
55

56
      {
57
        route: r,
2,724✔
58
        verb: r.verb,
59
        path: path,
60
        path_with_params: r.format(
61
          r.required_parts.each_with_index.map { |p, i| [ p, path_params[i] ] }.to_h,
1,650✔
62
        ),
63
        relative_path: relative_path,
64
        concat_path: concat_path,
65
        controller: r.defaults[:controller].presence,
66
        action: r.defaults[:action].presence,
67
        matches_path: matches_path,
68
        matches_params: matches_params,
69
        # The following options are only used in subsequent processing in this method.
70
        _levels: levels,
71
      }
72
    }.sort_by { |r|
73
      [
74
        # Sort by levels first, so routes matching closely with current request show first.
75
        r[:_levels],
2,724✔
76
        # Then match by path, but manually sort ':' to the end using knowledge that Ruby sorts the
77
        # pipe character '|' after alphanumerics.
78
        r[:path].tr(":", "|"),
79
        # Finally, match by HTTP verb.
80
        HTTP_VERB_ORDERING.index(r[:verb]) || 99,
81
      ]
82
    }.group_by { |r| r[:controller] }.sort_by { |c, _r|
2,724✔
83
      # Sort the controller groups by current controller first, then alphanumerically.
84
      # Note: Use `controller_path` instead of `params[:controller]` to avoid re-raising a
85
      # `ActionDispatch::Http::Parameters::ParseError` exception.
86
      [ request.controller_class.controller_path == c ? 0 : 1, c ]
328✔
87
    }.to_h
88
  end
89

90
  # Custom inflector for RESTful controllers.
91
  def self.inflect(s, acronyms = nil)
2✔
92
    acronyms&.each do |acronym|
1,214✔
93
      s = s.gsub(/\b#{acronym}\b/i, acronym)
6,070✔
94
    end
95

96
    s
1,214✔
97
  end
98

99
  # Parse fields hashes.
100
  def self.parse_fields_hash(h, model, exclude_associations:, action_text:, active_storage:)
2✔
101
    parsed_fields = h[:only] || (
98✔
102
      model ? self.fields_for(
64✔
103
        model,
104
        exclude_associations: exclude_associations,
105
        action_text: action_text,
106
        active_storage: active_storage,
107
      ) : []
108
    )
109
    parsed_fields += h[:include].map(&:to_s) if h[:include]
98✔
110
    parsed_fields -= h[:exclude].map(&:to_s) if h[:exclude]
98✔
111
    parsed_fields -= h[:except].map(&:to_s) if h[:except]
98✔
112

113
    # Warn for any unknown keys.
114
    (h.keys - [ :only, :except, :include, :exclude ]).each do |k|
98✔
115
      Rails.logger.warn("RRF: Unknown key in fields hash: #{k}.")
×
116
    end
117

118
    # We should always return strings, not symbols.
119
    parsed_fields.map(&:to_s)
98✔
120
  end
121

122
  # Get the fields for a given model, including not just columns (which includes foreign keys), but
123
  # also associations. Note that we always return an array of strings, not symbols.
124
  def self.fields_for(model, exclude_associations:, action_text:, active_storage:)
2✔
125
    foreign_keys = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
750✔
126
    base_fields = model.column_names.reject { |c| c.in?(foreign_keys) }
7,078✔
127

128
    return base_fields if exclude_associations
750✔
129

130
    # ActionText Integration: Determine the normalized field names for action text attributes.
131
    atf = action_text ? model.reflect_on_all_associations(:has_one).collect(&:name).select { |n|
750✔
132
      n.to_s.start_with?("rich_text_")
1,284✔
133
    }.map { |n| n.to_s.delete_prefix("rich_text_") } : []
340✔
134

135
    # ActiveStorage Integration: Determine the normalized field names for active storage attributes.
136
    asf = active_storage ? model.attachment_reflections.keys : []
750✔
137

138
    # Associations:
139
    associations = model.reflections.map { |association, ref|
750✔
140
      # Ignore associations for which we have custom integrations.
141
      if ref.class_name.in?(%w[ActionText::RichText ActiveStorage::Attachment ActiveStorage::Blob])
4,552✔
142
        next nil
1,700✔
143
      end
144

145
      if ref.collection? && RESTFramework.config.large_reverse_association_tables&.include?(
2,852✔
146
        ref.table_name,
147
      )
148
        next nil
×
149
      end
150

151
      next association
2,852✔
152
    }.compact
153

154
    base_fields + associations + atf + asf
750✔
155
  end
156

157
  # Get the association's fields that may be serialized and filtered/ordered for a reflection.
158
  def self.association_fields_for(ref)
2✔
159
    if !ref.polymorphic? && model = ref.klass
124✔
160
      fields = [ model.primary_key ].flatten.compact
124✔
161
      label_fields = RESTFramework.config.label_fields
124✔
162

163
      # Preferably find a database column to use as label.
164
      if match = label_fields.find { |f| f.in?(model.column_names) }
544✔
165
        return fields + [ match ]
112✔
166
      end
167

168
      # Otherwise, find a method.
169
      if match = label_fields.find { |f| model.method_defined?(f) }
96✔
NEW
170
        return fields + [ match ]
×
171
      end
172

173
      return fields
12✔
174
    end
175

176
    [ "id", "name" ]
×
177
  end
178

179
  # Get a field's id/ids variation.
180
  def self.id_field_for(field, reflection)
2✔
181
    if reflection.collection?
128✔
182
      return "#{field.singularize}_ids"
62✔
183
    elsif reflection.belongs_to?
66✔
184
      # The id field for belongs_to is always the foreign key column name, even if the
185
      # association is named differently.
186
      return reflection.foreign_key
48✔
187
    end
188

189
    nil
190
  end
191

192
  # Find the REST controller for `model` at the same namespace level as `current_controller`, e.g.
193
  # `Api::Demo::MoviesController` + `Genre` => `Api::Demo::GenresController`, or `nil` if none. The
194
  # `model` match guards against trusting a same-named controller for a different model.
195
  def self.controller_for_model(current_controller, model)
2✔
196
    return nil unless model && (base_name = current_controller.name)
46✔
197

198
    namespace = base_name.deconstantize
44✔
199
    model_name = model.model_name
44✔
200

201
    # Plural for a collection controller, singular for a singular-resource one.
202
    [ model_name.plural, model_name.singular ].each do |name|
44✔
203
      candidate_name = "#{name.camelize}Controller"
46✔
204
      candidate_name = "#{namespace}::#{candidate_name}" if namespace.present?
46✔
205

206
      candidate = candidate_name.safe_constantize
46✔
207
      next unless candidate.is_a?(Class) && candidate.include?(RESTFramework::Controller)
46✔
208
      next unless candidate.model == model
42✔
209

210
      return candidate
42✔
211
    end
212

213
    nil
214
  end
215

216
  # Wrap a serializer with an adapter if it is an ActiveModel::Serializer.
217
  def self.wrap_ams(s)
2✔
218
    if defined?(ActiveModel::Serializer) && (s < ActiveModel::Serializer)
270✔
219
      return RESTFramework::ActiveModelSerializerAdapterFactory.for(s)
×
220
    end
221

222
    s
270✔
223
  end
224
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc