rails_ninja 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 99a76115680edb87523d0a819db195e01041bd3ed09d6bfabc0226963505f2fc
4
+ data.tar.gz: bedb6982e08bd7c32864052ef14d130d0f555503006ceef2a3a0f557e6165912
5
+ SHA512:
6
+ metadata.gz: e21f4e9ea17198535d38b26a0f7e46d522cb09f84885eee3a92dc4e6c73e16606d446784262bff3841cae1578d3d88ce46ae1c66a6615564f1d2b4139a2ae891
7
+ data.tar.gz: dd8f2cbee49dd08ecbbd95fb86ebb2aef82db49eb37f477db1094441e9c39104e7c15b793bba91abf1f6310ecf7e76237994bf16f60bc1ecaa113d57d8d1c31f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fintual Open Source
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # Rails Ninja
2
+
3
+ Rails Ninja is a small Rails API framework inspired by
4
+ [Django Ninja](https://django-ninja.dev). It provides a route DSL, schema-based
5
+ request validation and response serialization, and generated OpenAPI
6
+ documentation.
7
+
8
+ Rails Ninja requires Ruby 3 or newer and Rails 7 or newer.
9
+
10
+ ## Installation
11
+
12
+ ```ruby
13
+ gem "rails_ninja"
14
+ ```
15
+
16
+ Then run `bundle install`.
17
+
18
+ ## Structure
19
+
20
+ Rails Ninja has three building blocks:
21
+
22
+ - `RailsNinja::API` is the root Rack application and OpenAPI document.
23
+ - `RailsNinja::EndpointGroup` groups related routes under a prefix and tag.
24
+ - `RailsNinja::Endpoint` keeps one endpoint and its schemas in a standalone
25
+ class.
26
+
27
+ An API can define routes inline, include standalone Endpoints, and mount
28
+ EndpointGroups. An API cannot mount another API: mount independent APIs
29
+ separately in Rails when they need separate documentation.
30
+
31
+ ### Endpoint
32
+
33
+ ```ruby
34
+ # app/api/endpoints/list_users.rb
35
+ class ListUsers < RailsNinja::Endpoint
36
+ schema :UserOut do
37
+ field :id, RailsNinja::Types::Int
38
+ field :name, RailsNinja::Types::String
39
+ field :email, RailsNinja::Types::String
40
+ end
41
+
42
+ get "/", response: [UserOut]
43
+ def handle
44
+ User.all
45
+ end
46
+ end
47
+ ```
48
+
49
+ ### EndpointGroup
50
+
51
+ ```ruby
52
+ # app/api/endpoint_groups/users_group.rb
53
+ class UsersGroup < RailsNinja::EndpointGroup
54
+ tags "Users"
55
+ ninja_headers "X-Request-ID"
56
+
57
+ include_endpoint ListUsers
58
+ end
59
+ ```
60
+
61
+ Groups may also define routes directly or mount other EndpointGroups.
62
+
63
+ ### API
64
+
65
+ ```ruby
66
+ # app/api/application_api.rb
67
+ class ApplicationApi < RailsNinja::API
68
+ title "My Service"
69
+ version "1.0"
70
+
71
+ mount UsersGroup, prefix: "/users"
72
+ end
73
+ ```
74
+
75
+ Mount the API in Rails:
76
+
77
+ ```ruby
78
+ # config/routes.rb
79
+ Rails.application.routes.draw do
80
+ mount ApplicationApi => "/api"
81
+ end
82
+ ```
83
+
84
+ Rails Ninja adds `app/api` to Rails' autoload and eager-load paths. The example
85
+ exposes:
86
+
87
+ - `GET /api/users`
88
+ - `GET /api/openapi.json`
89
+ - `GET /api/docs`
90
+
91
+ The endpoint verbs are `get`, `post`, `put`, `patch`, and `delete`. A
92
+ verb declaration applies to the method defined immediately after it.
93
+
94
+ ## Schemas
95
+
96
+ ```ruby
97
+ schema :ItemIn do
98
+ field :name, RailsNinja::Types::String
99
+ field :price, RailsNinja::Types::Float
100
+ field :active, RailsNinja::Types::Boolean, required: false, default: true
101
+ field :tags, [RailsNinja::Types::String], required: false, default: []
102
+ end
103
+ ```
104
+
105
+ Fields are required by default. Available scalar types are `String`, `Int`,
106
+ `Float`, and `Boolean` under `RailsNinja::Types`. A field may also contain a
107
+ nested schema or a one-element array of a scalar or schema.
108
+
109
+ JSON input is strictly type-checked. Canonical path, query, and form values are
110
+ decoded first, so an integer query value such as `"20"` becomes `20`. Invalid
111
+ requests return `422` with an `errors` array, and validated values are merged
112
+ into `params` as symbol keys.
113
+
114
+ For `GET` and `DELETE`, a request schema is read from and documented as query
115
+ parameters. `POST`, `PUT`, and `PATCH` use a request body.
116
+
117
+ Schemas may also be standalone:
118
+
119
+ ```ruby
120
+ class ItemOut < RailsNinja::Schema::Base
121
+ field :id, RailsNinja::Types::Int
122
+ field :name, RailsNinja::Types::String
123
+ end
124
+ ```
125
+
126
+ Use `one_of` for polymorphic response fields and OpenAPI schemas:
127
+
128
+ ```ruby
129
+ schema :Pet do
130
+ field :animal, one_of(Cat, Dog, discriminator: :kind)
131
+ end
132
+ ```
133
+
134
+ The discriminator is optional. Each variant needs a default value for its
135
+ discriminator field to appear in the OpenAPI mapping.
136
+
137
+ ## Requests and responses
138
+
139
+ ```ruby
140
+ post "/items", request: ItemIn, response: ItemOut
141
+ def create_item
142
+ Item.create!(params.slice(:name, :price, :active, :tags))
143
+ end
144
+ ```
145
+
146
+ `response: ItemOut` serializes one object; `response: [ItemOut]` serializes a
147
+ collection. Without a response schema, a normal return value is not rendered.
148
+ Use `render_json` or `head` for explicit responses:
149
+
150
+ ```ruby
151
+ get "/health"
152
+ def health
153
+ render_json({ status: "ok" })
154
+ end
155
+
156
+ delete "/items/:id"
157
+ def delete_item
158
+ Item.find(params[:id]).destroy!
159
+ head 204
160
+ end
161
+ ```
162
+
163
+ Document multiple statuses with `responses:`:
164
+
165
+ ```ruby
166
+ get "/items/:id", responses: { 200 => ItemOut, 404 => ErrorOut }
167
+ def show_item
168
+ item = Item.find_by(id: params[:id])
169
+ return render_json({ error: "Not found" }, status: 404) unless item
170
+
171
+ item
172
+ end
173
+ ```
174
+
175
+ Only the `200` schema is serialized automatically. Other statuses must be
176
+ committed with `render_json` or `head`.
177
+
178
+ ## Callbacks, headers, and tags
179
+
180
+ Before actions run from the API through the matched group branch to the
181
+ Endpoint. They may halt processing with `head` or `render_json`:
182
+
183
+ ```ruby
184
+ class InternalApi < RailsNinja::API
185
+ before_action :authenticate!
186
+ ninja_headers "X-API-Key"
187
+
188
+ def authenticate!
189
+ head 401 unless valid_api_key?(request.headers["X-API-Key"])
190
+ end
191
+ end
192
+ ```
193
+
194
+ Headers can also be declared per route:
195
+
196
+ ```ruby
197
+ get "/items", headers: [{ name: "X-Request-ID", required: false }]
198
+ def list_items
199
+ # ...
200
+ end
201
+ ```
202
+
203
+ Endpoint-level headers override class-level headers with the same name. Tags on
204
+ an EndpointGroup apply to its included Endpoints and determine their Swagger UI
205
+ group and `operationId` prefix.
206
+
207
+ ## OpenAPI authorization
208
+
209
+ Declare security metadata on the root API. Runtime authentication remains the
210
+ responsibility of a before action.
211
+
212
+ ```ruby
213
+ class InternalApi < RailsNinja::API
214
+ openapi_security_scheme(
215
+ :ApiKeyAuth,
216
+ type: "apiKey",
217
+ in: "header",
218
+ name: "X-API-Key"
219
+ )
220
+ openapi_security :ApiKeyAuth
221
+ end
222
+ ```
223
+
224
+ HTTP bearer schemes are also supported:
225
+
226
+ ```ruby
227
+ openapi_security_scheme :UserAuth, type: "http", scheme: "bearer"
228
+ openapi_security :UserAuth
229
+ ```
230
+
231
+ ## Endpoint options
232
+
233
+ Routes accept `summary:`, `tags:`, `headers:`, and `deprecated_paths:`:
234
+
235
+ ```ruby
236
+ get "/items",
237
+ summary: "List items",
238
+ deprecated_paths: ["/old_items"]
239
+ def list_items
240
+ # ...
241
+ end
242
+ ```
243
+
244
+ Deprecated paths remain routable and are marked as deprecated in OpenAPI.
245
+
246
+ Set `server "https://api.example.com"` on an API to declare its server URL, or
247
+ `docs false` to disable `/docs` and `/openapi.json`.
248
+
249
+ ## Static OpenAPI files
250
+
251
+ Generate an OpenAPI 3.2 JSON file for every API:
252
+
253
+ ```sh
254
+ bundle exec rake rails_ninja:openapi:generate
255
+ bundle exec rake rails_ninja:openapi:generate OUTPUT=docs/api
256
+ ```
257
+
258
+ The default output directory is `public/openapi`. File names come from the API
259
+ class name, such as `PublicApi` to `public_api.json`.
260
+
261
+ ## Development
262
+
263
+ Install dependencies and run the test suite:
264
+
265
+ ```sh
266
+ bundle install
267
+ bundle exec rake test
268
+ ```
269
+
270
+ CI tests every compatible combination of Action Pack and Active Support 7.0
271
+ through 8.1 with MRI Ruby 3.0 through 4.0. Each lane resolves the latest patch
272
+ release in its minor series.
273
+
274
+ ## License
275
+
276
+ MIT
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ class API < EndpointGroup
5
+ FORM_MEDIA_TYPES = %w[application/x-www-form-urlencoded multipart/form-data].freeze
6
+
7
+ abstract!
8
+ include ActionController::Instrumentation
9
+
10
+ # --- Class-level DSL ---
11
+
12
+ class << self
13
+ # Config DSL
14
+ def title(value = nil)
15
+ if value
16
+ @_title = value
17
+ else
18
+ @_title
19
+ end
20
+ end
21
+
22
+ def _title
23
+ @_title
24
+ end
25
+
26
+ def version(value = nil)
27
+ if value
28
+ @_version = value
29
+ else
30
+ @_version
31
+ end
32
+ end
33
+
34
+ def _version
35
+ @_version
36
+ end
37
+
38
+ def server(value = nil)
39
+ if value
40
+ @_server = value
41
+ else
42
+ @_server
43
+ end
44
+ end
45
+
46
+ def _server
47
+ @_server
48
+ end
49
+
50
+ def docs(enabled = nil)
51
+ if enabled.nil?
52
+ @_docs_enabled
53
+ else
54
+ @_docs_enabled = enabled
55
+ end
56
+ end
57
+
58
+ def _docs_enabled?
59
+ return @_docs_enabled unless @_docs_enabled.nil?
60
+
61
+ true
62
+ end
63
+
64
+ def openapi_security_scheme(name, **options)
65
+ _openapi_security_schemes[name.to_s] = options
66
+ end
67
+
68
+ def _openapi_security_schemes
69
+ @_openapi_security_schemes ||= {}
70
+ end
71
+
72
+ def openapi_security(*names)
73
+ if names.any?
74
+ @_openapi_security = names.flatten.map(&:to_s)
75
+ else
76
+ @_openapi_security
77
+ end
78
+ end
79
+
80
+ def _openapi_security
81
+ @_openapi_security || []
82
+ end
83
+
84
+ def inherited(subclass)
85
+ super
86
+ subclass.instance_variable_set(:@_openapi_security_schemes, _openapi_security_schemes.transform_values(&:dup))
87
+ subclass.instance_variable_set(:@_openapi_security, _openapi_security.dup)
88
+ RailsNinja.registered_apis << subclass
89
+ end
90
+
91
+ # Rack interface — makes this class usable with Rack::Test and as a Rack app
92
+ def call(env)
93
+ path = env["PATH_INFO"] || "/"
94
+ verb = env["REQUEST_METHOD"]
95
+
96
+ if _docs_enabled?
97
+ return Response.html(Swagger::UI.html(spec_url: "./openapi.json")) if verb == "GET" && path == "/docs"
98
+ return Response.json(OpenAPI::Generator.new(self).to_hash) if verb == "GET" && path == "/openapi.json"
99
+ end
100
+
101
+ match = _match_route(verb, path)
102
+ return Response.error("Not Found", status: 404) unless match
103
+
104
+ endpoint, full_path, dispatch_name, _regex, mount_path = match
105
+ path_params = _extract_path_params(full_path, path)
106
+ env["action_dispatch.request.path_parameters"] = path_params.merge(
107
+ controller: controller_path || "rails_ninja",
108
+ action: dispatch_name.to_s
109
+ )
110
+ env["rails_ninja.endpoint"] = endpoint
111
+ env["rails_ninja.mount_path"] = mount_path
112
+
113
+ req = ActionDispatch::Request.new(env)
114
+ res = make_response!(req)
115
+ dispatch(dispatch_name.to_s, req, res)
116
+ end
117
+
118
+ def _match_route(verb, path)
119
+ _cached_dispatch_table.find do |_ep, _full_path, _dispatch_name, regex|
120
+ _ep.verb.to_s.upcase == verb.upcase && regex.match?(path)
121
+ end
122
+ end
123
+
124
+ def _cached_dispatch_table
125
+ @_cached_dispatch_table ||= _dispatch_table
126
+ end
127
+
128
+ def _extract_path_params(full_path, actual_path)
129
+ regex = _path_to_regex(full_path)
130
+ match = regex.match(actual_path)
131
+ match ? match.named_captures.transform_keys(&:to_sym) : {}
132
+ end
133
+
134
+ # Builds a flat dispatch table: [operation, full_path, dispatch_name, regex, mount_path]
135
+ # For the API's own endpoints, dispatch_name == handler.
136
+ # For endpoints of mounted groups, dispatch_name is the unique method defined on the API.
137
+ # mount_path is the branch of group classes leading to the operation — the same operation
138
+ # object can appear on several branches when a group is mounted more than once.
139
+ def _dispatch_table(group_class = self, prefix = "/", mount_path = nil)
140
+ mount_path ||= [group_class]
141
+ results = []
142
+
143
+ group_class._endpoints.each do |endpoint|
144
+ full_path = normalize_route_path("#{prefix}/#{endpoint.path}")
145
+ regex = _path_to_regex(full_path)
146
+ dispatch_name = if group_class == self
147
+ endpoint.handler
148
+ else
149
+ _unique_handler_name(endpoint.api_class, endpoint.display_handler)
150
+ end
151
+ results << [endpoint, full_path, dispatch_name, regex, mount_path]
152
+ end
153
+
154
+ group_class._mounted_groups.each do |mounted|
155
+ sub_prefix = normalize_route_path("#{prefix}/#{mounted[:prefix]}")
156
+ results.concat(_dispatch_table(mounted[:group_class], sub_prefix, mount_path + [mounted[:group_class]]))
157
+ end
158
+
159
+ results
160
+ end
161
+
162
+ def _path_to_regex(path)
163
+ pattern = "^" + path.gsub(/:(\w+)/, '(?<\1>[^/]+)') + "$"
164
+ Regexp.new(pattern)
165
+ end
166
+
167
+ # Draw Rails routes for all endpoints in this API (including mounted groups)
168
+ def draw_routes(router_context, prefix: "/")
169
+ register_as_controller!
170
+
171
+ _dispatch_table(self, prefix).each do |endpoint, full_path, dispatch_name, _regex|
172
+ router_context.match full_path,
173
+ to: "#{controller_path}##{dispatch_name}",
174
+ via: endpoint.verb
175
+ end
176
+
177
+ draw_docs_routes(router_context, prefix) if _docs_enabled?
178
+ end
179
+
180
+ # Register this class so Rails can find it via controller_path + "Controller"
181
+ def register_as_controller!
182
+ return if @_registered_as_controller
183
+
184
+ # Rails looks up "controller_path_controller".camelize, so we register an alias
185
+ controller_class_name = "#{name}Controller"
186
+ parts = controller_class_name.split("::")
187
+ const_name = parts.pop
188
+ namespace = parts.empty? ? Object : parts.join("::").constantize
189
+ namespace.const_set(const_name, self) unless namespace.const_defined?(const_name, false)
190
+ @_registered_as_controller = true
191
+ end
192
+
193
+ private
194
+
195
+ def draw_docs_routes(router_context, prefix)
196
+ api_class = self
197
+ docs_path = normalize_route_path("#{prefix}/docs")
198
+ openapi_path = normalize_route_path("#{prefix}/openapi.json")
199
+
200
+ router_context.match docs_path, to: ->(_env) {
201
+ body = Swagger::UI.html(spec_url: "./openapi.json")
202
+ [200, { "content-type" => "text/html" }, [body]]
203
+ }, via: :get
204
+
205
+ router_context.match openapi_path, to: ->(_env) {
206
+ generator = OpenAPI::Generator.new(api_class)
207
+ [200, { "content-type" => "application/json" }, [generator.to_json]]
208
+ }, via: :get
209
+ end
210
+
211
+ def normalize_route_path(path)
212
+ "/" + path.squeeze("/").gsub(%r{^/|/$}, "")
213
+ end
214
+ end
215
+
216
+ # Let Instrumentation wrap this so "Processing"/"Completed" logs appear
217
+ def process_action(action_name, *args)
218
+ @_ninja_endpoint, @_ninja_mount_path = find_route(action_name)
219
+
220
+ unless @_ninja_endpoint
221
+ head(:not_found)
222
+ return
223
+ end
224
+
225
+ super
226
+ rescue ValidationError => e
227
+ self.status = 422
228
+ self.content_type = "application/json"
229
+ self.response_body = [MultiJson.dump({ errors: e.errors })]
230
+ rescue NotFoundError => e
231
+ self.status = 404
232
+ self.content_type = "application/json"
233
+ self.response_body = [MultiJson.dump({ error: e.message })]
234
+ end
235
+
236
+ # Override send_action (called by AbstractController::Base#process_action via super chain)
237
+ # to inject before_actions, validation, and result rendering
238
+ def send_action(action_name)
239
+ endpoint = @_ninja_endpoint
240
+
241
+ @_ninja_handler_instance = build_group_instance(endpoint.api_class) unless endpoint.api_class == self.class
242
+ run_ancestor_before_actions(endpoint)
243
+ unless performed?
244
+ run_before_actions(endpoint)
245
+ end
246
+ unless performed?
247
+ validate_request!(endpoint)
248
+ end
249
+ unless performed?
250
+ result = super
251
+ render_result(result, endpoint) unless performed?
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ def find_route(action_name)
258
+ # When dispatched via API.call, the exact route is stored in the env
259
+ stored = request.env["rails_ninja.endpoint"]
260
+ return [stored, request.env["rails_ninja.mount_path"]] if stored
261
+
262
+ # When dispatched via Rails routes, match by the dispatch method name
263
+ action_sym = action_name.to_sym
264
+ rows = self.class._cached_dispatch_table.select do |_op, _path, dispatch_name, _regex, _mount_path|
265
+ dispatch_name == action_sym
266
+ end
267
+ row = rows.one? ? rows.first : disambiguate_row(rows)
268
+ return [nil, nil] unless row
269
+
270
+ [row[0], row[4]]
271
+ end
272
+
273
+ # A dispatch name can map to several table rows (a group mounted more than
274
+ # once, or deprecated path aliases); pick the row whose path matches the
275
+ # Rails route that dispatched this request.
276
+ def disambiguate_row(rows)
277
+ pattern = request.respond_to?(:route_uri_pattern) && request.route_uri_pattern&.delete_suffix("(.:format)")
278
+ return rows.first unless pattern
279
+
280
+ rows.select { |_op, full_path, _dispatch_name, _regex, _mount_path| pattern.end_with?(full_path) }
281
+ .max_by { |_op, full_path, _dispatch_name, _regex, _mount_path| full_path.length } || rows.first
282
+ end
283
+
284
+ def run_ancestor_before_actions(endpoint)
285
+ groups = @_ninja_mount_path || [self.class]
286
+ groups.each do |group|
287
+ next if group == endpoint.api_class
288
+ next if group._before_actions.empty?
289
+
290
+ break if run_actions_list(group._before_actions, build_group_instance(group))
291
+ end
292
+ end
293
+
294
+ def run_before_actions(endpoint)
295
+ if endpoint.api_class == self.class
296
+ run_actions_list(endpoint.api_class._before_actions)
297
+ else
298
+ run_actions_list(endpoint.api_class._before_actions, @_ninja_handler_instance)
299
+ end
300
+ end
301
+
302
+ def build_group_instance(group)
303
+ instance = group.new
304
+ instance.set_request!(request)
305
+ instance.set_response!(response)
306
+ instance
307
+ end
308
+
309
+ def run_actions_list(actions, group_instance = nil)
310
+ actions.each do |action|
311
+ if group_instance && action.is_a?(Symbol)
312
+ group_instance.public_send(action)
313
+
314
+ if group_instance.performed?
315
+ self.status = group_instance.status
316
+ self.content_type = group_instance.content_type
317
+ self.response_body = group_instance.response_body
318
+ end
319
+ elsif action.is_a?(Symbol)
320
+ public_send(action)
321
+ else
322
+ instance_exec(&action)
323
+ end
324
+
325
+ break if performed?
326
+ end
327
+
328
+ performed?
329
+ end
330
+
331
+ def validate_request!(endpoint)
332
+ return unless endpoint.request_schema
333
+
334
+ input = request_input(endpoint.request_schema)
335
+ validated, errors = endpoint.request_schema.validate(input)
336
+
337
+ if errors.empty?
338
+ params.merge!(validated)
339
+ return
340
+ end
341
+
342
+ self.status = 422
343
+ self.content_type = "application/json"
344
+ self.response_body = [MultiJson.dump({ errors: errors })]
345
+ end
346
+
347
+ def request_input(schema_class)
348
+ path = decode_parameters(schema_class, request.path_parameters)
349
+ query = decode_parameters(schema_class, request.query_parameters)
350
+ body = if FORM_MEDIA_TYPES.include?(request.media_type)
351
+ decode_parameters(schema_class, request.request_parameters)
352
+ else
353
+ request.request_parameters.deep_symbolize_keys
354
+ end
355
+
356
+ path.merge(query).merge(body)
357
+ end
358
+
359
+ def decode_parameters(schema_class, parameters)
360
+ Schema::ParameterDecoder.new(schema_class, parameters).call
361
+ end
362
+
363
+ def render_result(result, endpoint)
364
+ if result.is_a?(Array) && result.length == 3 && result[0].is_a?(Integer)
365
+ self.status = result[0]
366
+ result[1].each { |k, v| headers[k] = v }
367
+ self.response_body = result[2]
368
+ elsif result.is_a?(Integer) && result.between?(100, 599)
369
+ head result
370
+ elsif endpoint.response_schema
371
+ body = if endpoint.response_is_array?
372
+ endpoint.response_schema.first.serialize_many(result)
373
+ else
374
+ endpoint.response_schema.serialize(result)
375
+ end
376
+ self.status = 200
377
+ self.content_type = "application/json"
378
+ self.response_body = [MultiJson.dump(body)]
379
+ else
380
+ # Nothing was rendered explicitly and no response schema is declared:
381
+ # return an empty body instead of serializing whatever the handler
382
+ # happened to return (e.g. a job object from perform_later).
383
+ self.status = 200
384
+ self.response_body = []
385
+ end
386
+ end
387
+ end
388
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNinja
4
+ class Endpoint < EndpointGroup
5
+ end
6
+ end