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

gregschmit / rails-rest-framework / 24538335705

16 Apr 2026 10:51PM UTC coverage: 88.164% (-2.7%) from 90.879%
24538335705

push

github

gregschmit
Refactor mixins into single Controller include.

Makes overloading builtin actions simpler.
Keep mixins backwards compatible, but replace with `model`, `bulk`, and
`excluded_actions` config attributes.
Remove UnknownModelError; no more auto model detection.
Update README.md.
Update test/demo APIs to use the new style, keep plain API on old mixin
style.
Ensure usage of old mixins triggers deprecation notices.
Update dependencies.
Update GH actions checkout to v6.

445 of 548 new or added lines in 13 files covered. (81.2%)

1095 of 1242 relevant lines covered (88.16%)

214.01 hits per line

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

90.72
/lib/rest_framework/controller/openapi.rb
1
module RESTFramework::Controller
2✔
2
  module ClassMethods
2✔
3
    def openapi_response_content_types
2✔
4
      @openapi_response_content_types ||= [
24✔
5
        "text/html",
6
        self.serialize_to_json ? "application/json" : nil,
8✔
7
        self.serialize_to_xml ? "application/xml" : nil,
8✔
8
      ].compact
9
    end
10

11
    def openapi_request_content_types
2✔
12
      @openapi_request_content_types ||= [
8✔
13
        "application/json",
14
        "application/x-www-form-urlencoded",
15
        "multipart/form-data",
16
      ]
17
    end
18

19
    def openapi_paths(routes, tag)
2✔
20
      resp_cts = self.openapi_response_content_types
8✔
21
      req_cts = self.openapi_request_content_types
8✔
22
      schema_name = self.openapi_schema_name if self.model
8✔
23

24
      routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
80✔
25
        [
26
          concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
20✔
27
          routes.map { |route|
28
            metadata = RESTFramework::ROUTE_METADATA[route[:path]] || {}
72✔
29
            summary = metadata.delete(:label).presence || self.label_for(route[:action])
72✔
30
            description = metadata.delete(:description).presence
72✔
31
            extra_action = RESTFramework::EXTRA_ACTION_ROUTES.include?(route[:path])
72✔
32
            error_response = { "$ref" => "#/components/responses/BadRequest" }
72✔
33
            not_found_response = { "$ref" => "#/components/responses/NotFound" }
72✔
34
            spec = { tags: [ tag ], summary: summary, description: description }.compact
72✔
35

36
            # All routes should have a successful response.
37
            success_code = if !extra_action
72✔
38
              if route[:action] == "create"
68✔
39
                201
8✔
40
              elsif route[:action] == "destroy"
60✔
41
                204
8✔
42
              else
43
                200
52✔
44
              end
45
            else
46
              200
4✔
47
            end
48
            spec[:responses] = {
72✔
49
              success_code => {
50
                content: resp_cts.map { |ct|
51
                  [
52
                    ct,
216✔
53
                    (self.model && !extra_action && route[:verb] != "OPTIONS") ? {
216✔
54
                      schema: { "$ref" => "#/components/schemas/#{schema_name}" },
55
                    } : {},
56
                  ]
57
                }.to_h,
58
                description: "Success",
59
              },
60
            }
61

62
            # Builtin POST, PUT, PATCH, and DELETE should have a 400 and 404 response.
63
            if route[:verb].in?([ "POST", "PUT", "PATCH", "DELETE" ]) && !extra_action
72✔
64
              spec[:responses][400] = error_response
44✔
65
              spec[:responses][404] = not_found_response
44✔
66
            end
67

68
            # All POST, PUT, PATCH should have a request body.
69
            if route[:verb].in?([ "POST", "PUT", "PATCH" ])
72✔
70
              spec[:requestBody] ||= {
32✔
71
                content: req_cts.map { |ct|
72
                  [
73
                    ct,
96✔
74
                    (self.model && !extra_action) ? {
96✔
75
                      schema: { "$ref" => "#/components/schemas/#{schema_name}" },
76
                    } : {},
77
                  ]
78
                }.to_h,
79
              }
80
            end
81

82
            # Add remaining metadata as an extension.
83
            spec["x-rrf-metadata"] = metadata if metadata.present?
72✔
84

85
            next route[:verb].downcase, spec
72✔
86
          }.to_h.merge(
87
            {
88
              parameters: routes.first[:route].required_parts.map { |p|
89
                {
90
                  name: p,
10✔
91
                  in: "path",
92
                  required: true,
93
                  schema: { type: "integer" },
94
                }
95
              },
96
            },
97
          ),
98
        ]
99
      }.to_h
100
    end
101

102
    def openapi_document(request, route_group_name, routes)
2✔
103
      server = request.base_url + request.original_fullpath.gsub(/\?.*/, "")
8✔
104

105
      {
106
        openapi: "3.1.1",
8✔
107
        info: {
108
          title: self.get_title,
109
          description: self.description,
110
          version: self.version.to_s,
111
        }.compact,
112
        servers: [ { url: server } ],
113
        paths: self.openapi_paths(routes, route_group_name),
114
        tags: [ { name: route_group_name, description: self.description }.compact ],
115
        components: {
116
          schemas: {
117
            "Error" => {
118
              type: "object",
119
              required: [ "message" ],
120
              properties: {
121
                message: { type: "string" },
122
                errors: { type: "object" },
123
                exception: { type: "string" },
124
              },
125
            },
126
          }.merge(self.model ? { self.openapi_schema_name => self.openapi_schema } : {}),
8✔
127
          responses: {
128
            "BadRequest": {
129
              description: "Bad Request",
130
              content: self.openapi_response_content_types.map { |ct|
131
                [
132
                  ct,
24✔
133
                  ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
24✔
134
                ]
135
              }.to_h,
136
            },
137
            "NotFound": {
138
              description: "Not Found",
139
              content: self.openapi_response_content_types.map { |ct|
140
                [
141
                  ct,
24✔
142
                  ct == "text/html" ? {} : { schema: { "$ref" => "#/components/schemas/Error" } },
24✔
143
                ]
144
              }.to_h,
145
            },
146
          },
147
        },
148
      }.merge(self.model ? {
8✔
149
        "x-rrf-primary_key" => self.model.primary_key,
150
        "x-rrf-callbacks" => self._process_action_callbacks.as_json,
151

152
        # While bulk update/destroy are obvious because they create new router endpoints, bulk
153
        # create overloads the existing collection `POST` endpoint, so we add a special key to the
154
        # OpenAPI metadata to indicate bulk create is supported.
155
        "x-rrf-bulk-create": self.bulk,
156
      } : {}).compact
157
    end
158

159
    # Only for model controllers.
160
    def openapi_schema
2✔
161
      return @openapi_schema if @openapi_schema
8✔
162

163
      field_configuration = self.field_configuration
8✔
164
      @openapi_schema = {
165
        required: field_configuration.select { |_, cfg| cfg[:required] }.keys,
122✔
166
        type: "object",
167
        properties: field_configuration.map { |f, cfg|
168
          v = { title: cfg[:label] }
114✔
169

170
          if cfg[:kind] == "association"
114✔
171
            v[:type] = cfg[:reflection].collection? ? "array" : "object"
36✔
172
          elsif cfg[:kind] == "rich_text"
78✔
173
            v[:type] = "string"
2✔
174
            v[:"x-rrf-rich_text"] = true
2✔
175
          elsif cfg[:kind] == "attachment"
76✔
176
            v[:type] = "string"
4✔
177
            v[:"x-rrf-attachment"] = cfg[:attachment_type]
4✔
178
          else
179
            v[:type] = cfg[:type]
72✔
180
          end
181

182
          v[:readOnly] = true if cfg[:read_only]
114✔
183
          v[:default] = cfg[:default] if cfg.key?(:default)
114✔
184

185
          if enum_variants = cfg[:enum_variants]
114✔
186
            v[:enum] = enum_variants.keys
4✔
187
            v[:"x-rrf-enum_variants"] = enum_variants
4✔
188
          end
189

190
          if validators = cfg[:validators]
114✔
191
            v[:"x-rrf-validators"] = validators
24✔
192
          end
193

194
          v[:"x-rrf-kind"] = cfg[:kind] if cfg[:kind]
114✔
195

196
          if cfg[:reflection]
114✔
197
            v[:"x-rrf-reflection"] = {
36✔
198
              class_name: cfg[:reflection].class_name,
199
              foreign_key: cfg[:reflection].foreign_key,
200
              association_foreign_key: cfg[:reflection].association_foreign_key,
201
              association_primary_key: cfg[:reflection].association_primary_key,
202
              inverse_of: cfg[:reflection].inverse_of&.name,
203
              join_table: cfg[:reflection].join_table,
204
            }.compact
205
            v[:"x-rrf-association_pk"] = cfg[:association_pk]
36✔
206
            v[:"x-rrf-sub_fields"] = cfg[:sub_fields]
36✔
207
            v[:"x-rrf-sub_fields_metadata"] = cfg[:sub_fields_metadata]
36✔
208
            v[:"x-rrf-id_field"] = cfg[:id_field]
36✔
209
            v[:"x-rrf-nested_attributes_options"] = cfg[:nested_attributes_options]
36✔
210
          end
211

212
          next [ f, v ]
114✔
213
        }.to_h,
214
      }
215

216
      @openapi_schema
8✔
217
    end
218

219
    # Only for model controllers.
220
    def openapi_schema_name
2✔
221
      @openapi_schema_name ||= self.name.chomp("Controller").gsub("::", ".")
16✔
222
    end
223
  end
224

225
  def openapi_document
2✔
226
    first, *rest = self.route_groups.to_a
8✔
227
    document = self.class.openapi_document(request, *first)
8✔
228

229
    if self.class.openapi_include_children
8✔
NEW
230
      rest.each do |route_group_name, routes|
×
NEW
231
        controller = "#{routes.first[:route].defaults[:controller]}_controller".camelize.constantize
×
NEW
232
        child_document = controller.openapi_document(request, route_group_name, routes)
×
233

234
        # Merge child paths and tags into the parent document.
NEW
235
        document[:paths].merge!(child_document[:paths])
×
NEW
236
        document[:tags] += child_document[:tags]
×
237

238
        # If the child document has schemas, merge them into the parent document.
NEW
239
        if schemas = child_document.dig(:components, :schemas)  # rubocop:disable Style/Next
×
NEW
240
          document[:components] ||= {}
×
NEW
241
          document[:components][:schemas] ||= {}
×
NEW
242
          document[:components][:schemas].merge!(schemas)
×
243
        end
244
      end
245
    end
246

247
    document
8✔
248
  end
249
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