model_driven_api 3.6.2 → 3.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ module Api
2
+ module OpenApi
3
+ class V3 < Base
4
+ def generate
5
+ paths = {}
6
+ paths.merge!(v3_authenticate_path)
7
+ paths.merge!(v3_raw_sql_paths)
8
+ paths.merge!(v3_info_paths)
9
+ ApplicationRecord.subclasses.sort_by(&:to_s).each do |model_class|
10
+ paths.merge!(v3_crud_paths(model_class))
11
+ paths.merge!(v3_custom_action_paths(model_class))
12
+ end
13
+ paths
14
+ end
15
+
16
+ def description
17
+ v3_description
18
+ end
19
+
20
+ private
21
+
22
+ def v3_authenticate_path
23
+ {
24
+ "/authenticate" => {
25
+ "post" => {
26
+ "summary" => "Authenticate",
27
+ "tags" => ["Authentication"],
28
+ "description" => "Exchange email/password for a JWT. The token is returned in the `Token` response header.",
29
+ "requestBody" => {
30
+ "required" => true,
31
+ "content" => {
32
+ "application/json" => {
33
+ "schema" => {
34
+ "type" => "object",
35
+ "properties" => {
36
+ "auth" => {
37
+ "type" => "object",
38
+ "properties" => {
39
+ "email" => { "type" => "string", "format" => "email" },
40
+ "password" => { "type" => "string", "format" => "password" },
41
+ },
42
+ },
43
+ },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ "responses" => {
49
+ "200" => {
50
+ "description" => "Authenticated — JWT in Token header",
51
+ "headers" => { "Token" => { "description" => "JWT", "schema" => { "type" => "string" } } },
52
+ },
53
+ "401" => { "description" => "Unauthorized" },
54
+ },
55
+ },
56
+ },
57
+ }
58
+ end
59
+
60
+ def v3_raw_sql_paths
61
+ response_schema = {
62
+ "type" => "array",
63
+ "items" => { "type" => "object", "additionalProperties" => true },
64
+ }
65
+ query_param = {
66
+ "name" => "query",
67
+ "in" => "query",
68
+ "required" => true,
69
+ "schema" => { "type" => "string" },
70
+ "example" => "SELECT id, name FROM roles LIMIT 10",
71
+ }
72
+ body_schema = {
73
+ "type" => "object",
74
+ "properties" => { "query" => { "type" => "string" } },
75
+ }
76
+ {
77
+ "/raw/sql" => {
78
+ "get" => {
79
+ "summary" => "Raw SQL (GET)",
80
+ "tags" => ["Raw"],
81
+ "description" => "Execute a SELECT query. Returns rows as a plain JSON array (not JSON:API).",
82
+ "security" => [{ "bearerAuth" => [] }],
83
+ "parameters" => [query_param],
84
+ "responses" => {
85
+ "200" => { "description" => "Rows", "content" => { "application/json" => { "schema" => response_schema } } },
86
+ "400" => { "description" => "Only SELECT statements are allowed" },
87
+ },
88
+ },
89
+ "post" => {
90
+ "summary" => "Raw SQL (POST)",
91
+ "tags" => ["Raw"],
92
+ "description" => "Execute a SELECT query. Returns rows as a plain JSON array (not JSON:API).",
93
+ "security" => [{ "bearerAuth" => [] }],
94
+ "requestBody" => { "required" => true, "content" => { "application/json" => { "schema" => body_schema } } },
95
+ "responses" => {
96
+ "200" => { "description" => "Rows", "content" => { "application/json" => { "schema" => response_schema } } },
97
+ "400" => { "description" => "Only SELECT statements are allowed" },
98
+ },
99
+ },
100
+ },
101
+ }
102
+ end
103
+
104
+ def v3_info_paths
105
+ {
106
+ "/info/version" => {
107
+ "get" => {
108
+ "summary" => "Version", "tags" => ["Info"],
109
+ "responses" => { "200" => { "description" => "App version string" } },
110
+ },
111
+ },
112
+ "/info/heartbeat" => {
113
+ "get" => {
114
+ "summary" => "Heartbeat", "tags" => ["Info"],
115
+ "security" => [{ "bearerAuth" => [] }],
116
+ "responses" => { "200" => { "description" => "Renews token, returns current user as plain JSON" } },
117
+ },
118
+ },
119
+ "/info/roles" => {
120
+ "get" => {
121
+ "summary" => "Roles", "tags" => ["Info"],
122
+ "security" => [{ "bearerAuth" => [] }],
123
+ "responses" => { "200" => { "description" => "All roles as plain JSON array" } },
124
+ },
125
+ },
126
+ "/info/schema" => {
127
+ "get" => {
128
+ "summary" => "Schema", "tags" => ["Info"],
129
+ "security" => [{ "bearerAuth" => [] }],
130
+ "responses" => { "200" => { "description" => "DB schema for models the user can read" } },
131
+ },
132
+ },
133
+ "/info/dsl" => {
134
+ "get" => {
135
+ "summary" => "DSL", "tags" => ["Info"],
136
+ "security" => [{ "bearerAuth" => [] }],
137
+ "responses" => { "200" => { "description" => "json_attrs DSL for each model" } },
138
+ },
139
+ },
140
+ "/info/translations" => {
141
+ "get" => {
142
+ "summary" => "Translations", "tags" => ["Info"],
143
+ "security" => [{ "bearerAuth" => [] }],
144
+ "parameters" => [{ "name" => "locale", "in" => "query", "schema" => { "type" => "string" } }],
145
+ "responses" => { "200" => { "description" => "Full i18n translation tree" } },
146
+ },
147
+ },
148
+ "/info/settings" => {
149
+ "get" => {
150
+ "summary" => "Settings", "tags" => ["Info"],
151
+ "security" => [{ "bearerAuth" => [] }],
152
+ "responses" => { "200" => { "description" => "All ThecoreSettings::Setting values" } },
153
+ },
154
+ },
155
+ "/info/swagger" => {
156
+ "get" => {
157
+ "summary" => "OpenAPI v3 spec", "tags" => ["Info"],
158
+ "responses" => { "200" => { "description" => "This OpenAPI 3.0 specification" } },
159
+ },
160
+ },
161
+ }
162
+ end
163
+
164
+ def v3_crud_paths(model_class)
165
+ resource_type = model_class.model_name.plural
166
+ tag = model_class.model_name.name
167
+
168
+ attrs = begin
169
+ props = create_properties_from_model(model_class, model_class.json_attrs || {})
170
+ props.reject { |k, _| k.to_s == "id" }
171
+ rescue
172
+ {}
173
+ end
174
+
175
+ writable_attrs = begin
176
+ create_properties_from_model(model_class, {}, true)
177
+ rescue
178
+ {}
179
+ end
180
+
181
+ resource_schema = {
182
+ "type" => "object",
183
+ "properties" => {
184
+ "id" => { "type" => "string" },
185
+ "type" => { "type" => "string", "example" => resource_type },
186
+ "attributes" => { "type" => "object", "properties" => attrs },
187
+ },
188
+ }
189
+ collection_schema = {
190
+ "type" => "object",
191
+ "properties" => {
192
+ "data" => { "type" => "array", "items" => resource_schema },
193
+ "meta" => { "type" => "object", "properties" => { "total" => { "type" => "integer" } } },
194
+ },
195
+ }
196
+ single_schema = { "type" => "object", "properties" => { "data" => resource_schema } }
197
+ request_schema = {
198
+ "type" => "object",
199
+ "properties" => {
200
+ "data" => {
201
+ "type" => "object",
202
+ "properties" => {
203
+ "type" => { "type" => "string", "example" => resource_type },
204
+ "attributes" => { "type" => "object", "properties" => writable_attrs },
205
+ },
206
+ },
207
+ },
208
+ }
209
+
210
+ filter_params = begin
211
+ model_class.ransackable_attributes.map do |attr|
212
+ { "name" => "filter[#{attr}]", "in" => "query", "schema" => { "type" => "string" } }
213
+ end
214
+ rescue
215
+ []
216
+ end
217
+
218
+ id_param = { "name" => "id", "in" => "path", "required" => true, "schema" => { "type" => "integer" } }
219
+ sort_param = { "name" => "sort", "in" => "query", "schema" => { "type" => "string" }, "description" => "field or -field (descending), comma-separated" }
220
+ page_params = [
221
+ { "name" => "page[number]", "in" => "query", "schema" => { "type" => "integer" } },
222
+ { "name" => "page[size]", "in" => "query", "schema" => { "type" => "integer" } },
223
+ ]
224
+ include_param = { "name" => "include", "in" => "query", "schema" => { "type" => "string" }, "description" => "Associations to sideload (empty to suppress defaults)" }
225
+ fields_param = { "name" => "fields[#{resource_type}]", "in" => "query", "schema" => { "type" => "string" }, "description" => "Sparse fieldsets" }
226
+
227
+ vnd = "application/vnd.api+json"
228
+
229
+ {
230
+ "/#{resource_type}" => {
231
+ "get" => {
232
+ "summary" => "Index #{tag}",
233
+ "tags" => [tag],
234
+ "security" => [{ "bearerAuth" => [] }],
235
+ "parameters" => [*page_params, *filter_params, sort_param, include_param, fields_param],
236
+ "responses" => {
237
+ "200" => { "description" => "Collection", "content" => { vnd => { "schema" => collection_schema } } },
238
+ },
239
+ },
240
+ "post" => {
241
+ "summary" => "Create #{tag}",
242
+ "tags" => [tag],
243
+ "security" => [{ "bearerAuth" => [] }],
244
+ "requestBody" => { "required" => true, "content" => { vnd => { "schema" => request_schema } } },
245
+ "responses" => {
246
+ "201" => { "description" => "Created", "content" => { vnd => { "schema" => single_schema } } },
247
+ },
248
+ },
249
+ },
250
+ "/#{resource_type}/{id}" => {
251
+ "get" => {
252
+ "summary" => "Show #{tag}",
253
+ "tags" => [tag],
254
+ "security" => [{ "bearerAuth" => [] }],
255
+ "parameters" => [id_param, include_param, fields_param],
256
+ "responses" => {
257
+ "200" => { "description" => "Resource", "content" => { vnd => { "schema" => single_schema } } },
258
+ "404" => { "description" => "Not found" },
259
+ },
260
+ },
261
+ "patch" => {
262
+ "summary" => "Update #{tag}",
263
+ "tags" => [tag],
264
+ "security" => [{ "bearerAuth" => [] }],
265
+ "parameters" => [id_param],
266
+ "requestBody" => { "required" => true, "content" => { vnd => { "schema" => request_schema } } },
267
+ "responses" => {
268
+ "200" => { "description" => "Updated", "content" => { vnd => { "schema" => single_schema } } },
269
+ "404" => { "description" => "Not found" },
270
+ },
271
+ },
272
+ "delete" => {
273
+ "summary" => "Destroy #{tag}",
274
+ "tags" => [tag],
275
+ "security" => [{ "bearerAuth" => [] }],
276
+ "parameters" => [id_param],
277
+ "responses" => {
278
+ "204" => { "description" => "No Content" },
279
+ "404" => { "description" => "Not found" },
280
+ },
281
+ },
282
+ },
283
+ }
284
+ end
285
+
286
+ def v3_custom_action_paths(model_class)
287
+ paths = {}
288
+ resource_type = model_class.model_name.plural
289
+ tag = model_class.model_name.name
290
+ custom_actions = ("Endpoints::#{tag}".constantize.instance_methods(false) rescue [])
291
+ custom_actions.each do |action|
292
+ definition = ("Endpoints::#{tag}".constantize.definitions[tag][action.to_sym] rescue nil)
293
+ next unless definition
294
+ definition.each { |_verb, spec| spec[:tags] = [tag] if spec.is_a?(Hash) }
295
+ has_id = definition.any? { |_v, spec| spec.is_a?(Hash) && spec[:parameters]&.any? { |p| p[:in] == "path" } }
296
+ path = "/#{resource_type}/custom_action/#{action}#{has_id ? "/{id}" : ""}"
297
+ paths[path] = definition
298
+ end
299
+ paths
300
+ end
301
+
302
+ def v3_description
303
+ <<~MD
304
+ ## API v3 — JSON:API
305
+
306
+ All resource endpoints follow the [JSON:API 1.0](https://jsonapi.org) specification.
307
+
308
+ ### Authentication
309
+
310
+ `POST /authenticate` → JWT in `Token` response header. Pass as `Authorization: Bearer <token>`.
311
+ Every successful request renews the token — always read and store the new `Token` header.
312
+
313
+ ### Content negotiation
314
+
315
+ `Accept: application/vnd.api+json` (GET) · `Content-Type: application/vnd.api+json` (write requests).
316
+
317
+ ### Filtering
318
+
319
+ `?filter[field]=value` — validated against each model's `ransackable_attributes`.
320
+
321
+ ### Sorting
322
+
323
+ `?sort=field` (asc) · `?sort=-field` (desc) · `?sort=field1,-field2` (multi-field).
324
+
325
+ ### Pagination
326
+
327
+ `?page[number]=N&page[size]=N` — response includes `meta.total`.
328
+
329
+ ### Sparse fieldsets
330
+
331
+ `?fields[type]=field1,field2` — restrict attributes returned per resource type.
332
+
333
+ ### Sideloading
334
+
335
+ Default sideloads come from each model's `json_attrs[:include]`.
336
+ Override with `?include=assoc1,assoc2` · suppress all with `?include=` (empty string).
337
+
338
+ ### Info & utility endpoints
339
+
340
+ `GET /info/version|heartbeat|roles|schema|dsl|translations|settings|swagger` — return plain JSON (not JSON:API).
341
+
342
+ ### Raw SQL escape hatch
343
+
344
+ `GET|POST /raw/sql` — SELECT-only; returns plain JSON array (not JSON:API).
345
+ MD
346
+ end
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,25 @@
1
+ module Api
2
+ ResourceAttributeSet = Struct.new(:attributes, :methods_list, :includes) do
3
+ def self.for(model_class, jattrs: nil)
4
+ jattrs ||= model_class.respond_to?(:json_attrs) ? (model_class.json_attrs || {}) : {}
5
+ only = Array(jattrs[:only]).map(&:to_sym)
6
+ if only.empty?
7
+ except = Array(jattrs[:except]).map(&:to_sym)
8
+ only = model_class.column_names.map(&:to_sym) - except
9
+ end
10
+ new(only.reject { |a| a == :id }, Array(jattrs[:methods]).map(&:to_sym), jattrs[:include])
11
+ end
12
+
13
+ # Parse json_attrs[:include] into { assoc_name => spec_or_nil }.
14
+ # Handles both symbol items (:roles) and hash items (users: { only: [:id] }).
15
+ def parsed_includes
16
+ return {} unless includes
17
+ Array(includes).each_with_object({}) do |item, hash|
18
+ case item
19
+ when Hash then item.each { |k, v| hash[k] = v }
20
+ when Symbol then hash[item] = nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,65 @@
1
+ module Api
2
+ module V3
3
+ class SerializerFactory
4
+ # Generate (and cache) a JSONAPI::Serializer subclass for model_class.
5
+ #
6
+ # nested_attrs: when set, use these attrs instead of model_class.json_attrs.
7
+ # Used for included associations — avoids reading the associated model's own
8
+ # json_attrs and prevents infinite recursion.
9
+ # parent_name: when set, the generated constant is named
10
+ # "#{model_class.name}For#{parent_name}Serializer" and include: is NOT
11
+ # processed (one level deep only).
12
+ def self.serializer_for(model_class, nested_attrs: nil, parent_name: nil)
13
+ const_name = parent_name ? "#{model_class.name}For#{parent_name}Serializer" : "#{model_class.name}Serializer"
14
+ return Api::V3.const_get(const_name) if Api::V3.const_defined?(const_name)
15
+
16
+ jattrs = nested_attrs || (model_class.respond_to?(:json_attrs) ? (model_class.json_attrs || {}) : {})
17
+ attr_set = Api::ResourceAttributeSet.for(model_class, jattrs: jattrs)
18
+
19
+ # Nested serializers are always flat — no recursive includes — to prevent
20
+ # infinite loops on circular associations (e.g. Role↔User).
21
+ includes_map = parent_name ? {} : attr_set.parsed_includes
22
+
23
+ type = model_class.model_name.plural.to_sym
24
+
25
+ reflections = model_class.reflect_on_all_associations.each_with_object({}) { |r, h| h[r.name] = r }
26
+ nested_info = includes_map.each_with_object({}) do |(assoc_name, assoc_spec), hash|
27
+ reflection = reflections[assoc_name]
28
+ next unless reflection
29
+ nested_ser = assoc_spec \
30
+ ? serializer_for(reflection.klass, nested_attrs: assoc_spec, parent_name: model_class.name) \
31
+ : serializer_for(reflection.klass)
32
+ hash[assoc_name] = { serializer: nested_ser, macro: reflection.macro }
33
+ end
34
+
35
+ klass = Class.new do
36
+ include JSONAPI::Serializer
37
+ set_type type
38
+ attributes(*attr_set.attributes) if attr_set.attributes.any?
39
+
40
+ attr_set.methods_list.each do |method_name|
41
+ attribute(method_name) { |object| object.send(method_name) }
42
+ end
43
+
44
+ nested_info.each do |assoc_name, info|
45
+ ser = info[:serializer]
46
+ case info[:macro]
47
+ when :has_many then has_many assoc_name, serializer: ser
48
+ when :has_one then has_one assoc_name, serializer: ser
49
+ when :belongs_to then belongs_to assoc_name, serializer: ser
50
+ end
51
+ end
52
+ end
53
+
54
+ Api::V3.const_set(const_name, klass)
55
+ klass
56
+ end
57
+
58
+ # Parse json_attrs[:include] into { assoc_name => spec_or_nil }.
59
+ # Delegates to Api::ResourceAttributeSet#parsed_includes.
60
+ def self.extract_includes(include_spec)
61
+ Api::ResourceAttributeSet.new([], [], include_spec).parsed_includes
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,8 +1,14 @@
1
1
  module ModelDrivenApi
2
2
  class Engine < ::Rails::Engine
3
3
  # appending migrations to the main app's ones
4
+ initializer :register_json_api_mime_type do
5
+ Mime::Type.register "application/vnd.api+json", :json_api unless Mime[:json_api]
6
+ ActionDispatch::Request.parameter_parsers[:json_api] =
7
+ ActionDispatch::Request.parameter_parsers[:json]
8
+ end
9
+
4
10
  initializer :append_migrations do |app|
5
- unless app.root.to_s.match root.to_s
11
+ unless app.root.to_s == root.to_s
6
12
  config.paths["db/migrate"].expanded.each do |expanded_path|
7
13
  app.config.paths["db/migrate"] << expanded_path
8
14
  end
@@ -1,3 +1,3 @@
1
1
  module ModelDrivenApi
2
- VERSION = "3.6.2".freeze
2
+ VERSION = "3.6.4".freeze
3
3
  end
@@ -6,6 +6,7 @@ require 'ransack'
6
6
  require 'jwt'
7
7
  require 'json_web_token'
8
8
  require "kaminari"
9
+ require "pagy"
9
10
  # require "multi_json"
10
11
  require "simple_command"
11
12
 
@@ -16,6 +17,13 @@ require 'deep_merge/rails_compat'
16
17
  require "model_driven_api/engine"
17
18
 
18
19
  require "safe_sql_executor"
20
+ require "api/resource_attribute_set"
21
+ require "api/model_resolver"
22
+ require "api/custom_action_dispatcher"
23
+ require "api/open_api/base"
24
+ require "api/open_api/v2"
25
+ require "api/open_api/v3"
26
+ require "api/v3/serializer_factory"
19
27
 
20
28
  module ModelDrivenApi
21
29
  def self.smart_merge src, dest
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: model_driven_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.6.2
4
+ version: 3.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriele Tassoni
@@ -94,19 +94,75 @@ dependencies:
94
94
  - !ruby/object:Gem::Version
95
95
  version: '1.2'
96
96
  - !ruby/object:Gem::Dependency
97
- name: sqlite3
97
+ name: jsonapi-serializer
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
- - - ">="
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '2.2'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.2'
110
+ - !ruby/object:Gem::Dependency
111
+ name: pagy
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '9.0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '9.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: pg
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
101
129
  - !ruby/object:Gem::Version
102
- version: '0'
130
+ version: '1.1'
103
131
  type: :development
104
132
  prerelease: false
105
133
  version_requirements: !ruby/object:Gem::Requirement
106
134
  requirements:
107
- - - ">="
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '1.1'
138
+ - !ruby/object:Gem::Dependency
139
+ name: rspec-rails
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '7.0'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '7.0'
152
+ - !ruby/object:Gem::Dependency
153
+ name: factory_bot_rails
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '6.4'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
108
164
  - !ruby/object:Gem::Version
109
- version: '0'
165
+ version: '6.4'
110
166
  description: Ruby on Rails REST APIs built by convention using the DB schema as the
111
167
  foundation, please see README for mode of use.
112
168
  email:
@@ -126,6 +182,12 @@ files:
126
182
  - app/controllers/api/v2/info_controller.rb
127
183
  - app/controllers/api/v2/raw_controller.rb
128
184
  - app/controllers/api/v2/users_controller.rb
185
+ - app/controllers/api/v3/application_controller.rb
186
+ - app/controllers/api/v3/auth/oauth_controller.rb
187
+ - app/controllers/api/v3/authentication_controller.rb
188
+ - app/controllers/api/v3/info_controller.rb
189
+ - app/controllers/api/v3/raw_controller.rb
190
+ - app/controllers/api/v3/users_controller.rb
129
191
  - app/models/endpoints/test_api.rb
130
192
  - app/models/test_api.rb
131
193
  - app/models/used_token.rb
@@ -138,6 +200,13 @@ files:
138
200
  - config/routes.rb
139
201
  - db/migrate/20210519145438_create_used_tokens.rb
140
202
  - db/migrate/20210528111450_rename_valid_to_is_valid_in_used_token.rb
203
+ - lib/api/custom_action_dispatcher.rb
204
+ - lib/api/model_resolver.rb
205
+ - lib/api/open_api/base.rb
206
+ - lib/api/open_api/v2.rb
207
+ - lib/api/open_api/v3.rb
208
+ - lib/api/resource_attribute_set.rb
209
+ - lib/api/v3/serializer_factory.rb
141
210
  - lib/concerns/api_exception_management.rb
142
211
  - lib/concerns/model_driven_api_application_record.rb
143
212
  - lib/concerns/model_driven_api_role.rb