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 +7 -0
- data/LICENSE +21 -0
- data/README.md +276 -0
- data/lib/rails_ninja/api.rb +388 -0
- data/lib/rails_ninja/endpoint.rb +6 -0
- data/lib/rails_ninja/endpoint_group.rb +219 -0
- data/lib/rails_ninja/errors.rb +20 -0
- data/lib/rails_ninja/openapi/generator.rb +290 -0
- data/lib/rails_ninja/openapi/schema_ref.rb +43 -0
- data/lib/rails_ninja/operation.rb +72 -0
- data/lib/rails_ninja/railtie.rb +17 -0
- data/lib/rails_ninja/response.rb +19 -0
- data/lib/rails_ninja/schema/field.rb +11 -0
- data/lib/rails_ninja/schema/one_of.rb +14 -0
- data/lib/rails_ninja/schema/parameter_decoder.rb +66 -0
- data/lib/rails_ninja/schema/serializer.rb +76 -0
- data/lib/rails_ninja/schema/validator.rb +104 -0
- data/lib/rails_ninja/schema.rb +61 -0
- data/lib/rails_ninja/swagger/ui.rb +37 -0
- data/lib/rails_ninja/tasks.rake +12 -0
- data/lib/rails_ninja/types/base_scalar.rb +25 -0
- data/lib/rails_ninja/types/boolean.rb +17 -0
- data/lib/rails_ninja/types/float.rb +17 -0
- data/lib/rails_ninja/types/int.rb +21 -0
- data/lib/rails_ninja/types/string.rb +17 -0
- data/lib/rails_ninja/version.rb +5 -0
- data/lib/rails_ninja.rb +52 -0
- data/rails_ninja.gemspec +37 -0
- metadata +172 -0
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
|