explicit 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/MIT-LICENSE +20 -0
- data/README.md +389 -0
- data/Rakefile +8 -0
- data/app/controllers/explicit/application_controller.rb +6 -0
- data/app/helpers/explicit/application_helper.rb +6 -0
- data/app/views/explicit/application/_documentation.html.erb +10 -0
- data/config/locales/en.yml +23 -0
- data/config/routes.rb +2 -0
- data/lib/explicit/documentation.rb +40 -0
- data/lib/explicit/engine.rb +9 -0
- data/lib/explicit/request/invalid_params.rb +23 -0
- data/lib/explicit/request/invalid_response_error.rb +17 -0
- data/lib/explicit/request.rb +73 -0
- data/lib/explicit/spec/agreement.rb +28 -0
- data/lib/explicit/spec/array.rb +28 -0
- data/lib/explicit/spec/boolean.rb +31 -0
- data/lib/explicit/spec/date_time_iso8601.rb +17 -0
- data/lib/explicit/spec/date_time_posix.rb +21 -0
- data/lib/explicit/spec/default.rb +20 -0
- data/lib/explicit/spec/error.rb +60 -0
- data/lib/explicit/spec/hash.rb +30 -0
- data/lib/explicit/spec/inclusion.rb +15 -0
- data/lib/explicit/spec/integer.rb +53 -0
- data/lib/explicit/spec/literal.rb +15 -0
- data/lib/explicit/spec/nilable.rb +15 -0
- data/lib/explicit/spec/one_of.rb +22 -0
- data/lib/explicit/spec/record.rb +31 -0
- data/lib/explicit/spec/string.rb +50 -0
- data/lib/explicit/spec.rb +64 -0
- data/lib/explicit/test_helper.rb +45 -0
- data/lib/explicit/version.rb +5 -0
- data/lib/explicit.rb +28 -0
- data/lib/tasks/schema/api_tasks.rake +4 -0
- metadata +94 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bdfcf17daab19ff8c25fa77a72b8e7d7bf085ee98726f898c7bc74819accda49
|
4
|
+
data.tar.gz: 2409883f725d14e47afc8509c3083c3e014713053b91b72bc48d13c1f1ea0b16
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e50376405bd6f2305de7716df8f414f26fc4b660999ec673bc9f20f54d41ccd06f535704c382ab2f636b789db34ba1b39f7929cce974dfb3440b16aa4f53e774
|
7
|
+
data.tar.gz: 5a6c7fb91762aff2a883165288b2a4d3a6caa5058a243a185c82fa9a4e9acf14fa8db68a3bba7a43aff3935a936f954ff86f46c5c49845ec930515a4f60b2741
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright Luiz Vasconcellos
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,389 @@
|
|
1
|
+
# Explicit
|
2
|
+
|
3
|
+
Explicit is a validation and documentation library for JSON APIs that enforces
|
4
|
+
documented specs during runtime.
|
5
|
+
|
6
|
+
1. [Installation](#installation)
|
7
|
+
2. [Defining requests](#defining-requests)
|
8
|
+
3. [Reusing specs](#reusing-specs)
|
9
|
+
4. [Writing tests](#writing-tests)
|
10
|
+
5. [Writing documentation](#writing-documentation)
|
11
|
+
6. [Performance benchmark](#performance-benchmark)
|
12
|
+
7. Specs
|
13
|
+
- [Agreement](#agreement)
|
14
|
+
- [Array](#array)
|
15
|
+
- [Boolean](#boolean)
|
16
|
+
- [Date Time ISO8601](#date-time-iso8601)
|
17
|
+
- [Date Time Posix](#date-time-posix)
|
18
|
+
- [Default](#default)
|
19
|
+
- [Hash](#hash)
|
20
|
+
- [Inclusion](#inclusion)
|
21
|
+
- [Integer](#integer)
|
22
|
+
- [Literal](#literal)
|
23
|
+
- [Nilable](#nilable)
|
24
|
+
- [One of](#one-of)
|
25
|
+
- [Record](#record)
|
26
|
+
- [String](#string)
|
27
|
+
8. Advanced configuration
|
28
|
+
- [Customizing error messages](#customizing-error-messages)
|
29
|
+
- [Customizing error serialization](#customizing-error-serialization)
|
30
|
+
|
31
|
+
# Installation
|
32
|
+
|
33
|
+
Add the following line to your Gemfile and then run `bundle install`:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
gem "explicit", "~> 0.1"
|
37
|
+
```
|
38
|
+
|
39
|
+
# Defining requests
|
40
|
+
|
41
|
+
You define request specs by inheriting from `Explicit::Request`. The following
|
42
|
+
methods are available:
|
43
|
+
|
44
|
+
- `get(path)` - Adds a route to the request. Use the syntax `:param` for path
|
45
|
+
params. For example: `get "/customers/:customer_id"`.
|
46
|
+
- There is also `head`, `post`, `put`, `delete`, `options` and `patch` for
|
47
|
+
other HTTP verbs.
|
48
|
+
- `title(text)` - Adds a title to the request. Displayed in the documentation.
|
49
|
+
- `description(text)` - Adds a description to the endpoint. Markdown supported.
|
50
|
+
- `header(name, spec)` - Adds a header to the endpoint.
|
51
|
+
- `param(name, spec, options = {})` - Adds the request param to the endpoint.
|
52
|
+
It works for params in the request body, query string and path params.
|
53
|
+
- `response(status, spec)` - Adds a response spec. You can add multiple
|
54
|
+
responses with different formats.
|
55
|
+
|
56
|
+
For example:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
class RegistrationsController < ActionController::API
|
60
|
+
class Request < Explicit::Request
|
61
|
+
post "/api/registrations"
|
62
|
+
|
63
|
+
description <<-MD
|
64
|
+
Attempts to register a new user in the system. If `payment_type` is not
|
65
|
+
specified a trial period of 30 days is started.
|
66
|
+
MD
|
67
|
+
|
68
|
+
param :name, [:string, empty: false]
|
69
|
+
param :email, [:string, format: URI::MailTo::EMAIL_REGEXP, strip: true]
|
70
|
+
param :payment_type, [:inclusion, ["free_trial", "credit_card"]], default: "free_trial"
|
71
|
+
param :terms_of_use, :agreement
|
72
|
+
|
73
|
+
response 200, { user: { id: :integer, email: :string } }
|
74
|
+
response 422, { error: "email_already_taken" }
|
75
|
+
end
|
76
|
+
|
77
|
+
def create
|
78
|
+
Request.validate!(params) => { name:, email:, payment_type: }
|
79
|
+
|
80
|
+
user = User.create!(name:, email:, payment_type:)
|
81
|
+
|
82
|
+
render json: user: { user.as_json(:id, :email) }
|
83
|
+
rescue ActiveRecord::RecordNotUnique
|
84
|
+
render json: { error: "email_already_taken" }, status: 422
|
85
|
+
end
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
# Reusing specs
|
90
|
+
|
91
|
+
Specs are just data. You can reuse specs the same way you reuse constants or
|
92
|
+
configs in your app. For example:
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
module MyApp::Spec
|
96
|
+
UUID = [:string, format: /^\h{8}-(\h{4}-){3}\h{12}$/].freeze
|
97
|
+
EMAIL = [:string, format: URI::MailTo::EMAIL_REGEXP, strip: true].freeze
|
98
|
+
|
99
|
+
ADDRESS = {
|
100
|
+
country_name: :string,
|
101
|
+
zipcode: [:string, format: /\d{6}-\d{3}/]
|
102
|
+
}.freeze
|
103
|
+
end
|
104
|
+
|
105
|
+
# ... and then reference the shared specs when needed
|
106
|
+
class Request < Explicit::Request
|
107
|
+
param :customer_uuid, MyApp::Spec::UUID
|
108
|
+
param :email, MyApp::Spec::EMAIL
|
109
|
+
param :address, MyApp::Spec::ADDRESS
|
110
|
+
end
|
111
|
+
```
|
112
|
+
|
113
|
+
# Writing tests
|
114
|
+
|
115
|
+
Include `Explicit::TestHelper` in your `test/test_helper.rb` or
|
116
|
+
`spec/rails_helper.rb`. This module provides the method
|
117
|
+
`fetch(request, params:, headers:)` that let's you verify the endpoint works as
|
118
|
+
expected and that it responds with a valid response according to the spec.
|
119
|
+
|
120
|
+
Add the following line to your `test/test_helper.rb`.
|
121
|
+
|
122
|
+
```diff
|
123
|
+
module ActiveSupport
|
124
|
+
class TestCase
|
125
|
+
fixtures :all
|
126
|
+
|
127
|
+
# Run tests in parallel with specified workers
|
128
|
+
parallelize(workers: :number_of_processors)
|
129
|
+
|
130
|
+
+ include Explicit::TestHelper
|
131
|
+
end
|
132
|
+
end
|
133
|
+
```
|
134
|
+
|
135
|
+
To test your controller, call `fetch(request, params:, headers:)` and write
|
136
|
+
assertions against the response. If the endpoint sends a response that does not
|
137
|
+
match expected spec the test fails with
|
138
|
+
`Explicit::Request::InvalidResponseError`.
|
139
|
+
|
140
|
+
The response object has a `status`, an integer value for the http status, and
|
141
|
+
`data`, a hash with the response data.
|
142
|
+
|
143
|
+
> Path params are matched by name, so if you have an endpoint configured with
|
144
|
+
> `put "/customers/:customer_id"` you must call as
|
145
|
+
> `fetch(CustomerController::UpdateRequest, { customer_id: 123 })`.
|
146
|
+
|
147
|
+
> Note: Response specs are only verified in test environment with no
|
148
|
+
> performance penalty when running in production.
|
149
|
+
|
150
|
+
```ruby
|
151
|
+
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
|
152
|
+
test "successful registration" do
|
153
|
+
response = fetch(RegistrationsController::Request, params: {
|
154
|
+
name: "Bilbo Baggins",
|
155
|
+
email: "bilbo@shire.com",
|
156
|
+
payment_type: "free_trial",
|
157
|
+
terms_of_use: true
|
158
|
+
})
|
159
|
+
|
160
|
+
assert_equal 200, response.status
|
161
|
+
|
162
|
+
assert response.data[:id]
|
163
|
+
assert_equal "bilbo@shire.com", response.data[:email]
|
164
|
+
end
|
165
|
+
end
|
166
|
+
```
|
167
|
+
|
168
|
+
# Writing documentation
|
169
|
+
|
170
|
+
Documentation is written . To make documentation available to users
|
171
|
+
you must configure it via `Explicit::Documentation.build` and then publish it
|
172
|
+
mounting it in `routes.rb`.
|
173
|
+
|
174
|
+
Inside `Explicit::Documentation.build` you have access to the following methods:
|
175
|
+
|
176
|
+
- `page_title(text)`
|
177
|
+
- `primary_color(hexcode)`
|
178
|
+
- `section(&block)`
|
179
|
+
- `add(request)`
|
180
|
+
- `add(title:, partial:)`
|
181
|
+
|
182
|
+
For example:
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
module MyApp::API::V1
|
186
|
+
Documentation = Explicit::Documentation.build do
|
187
|
+
page_title "Acme Co. | API Docs"
|
188
|
+
primary_color "#6366f1"
|
189
|
+
|
190
|
+
section "Introduction" do
|
191
|
+
add title: "About", partial: "api/v1/introduction/about"
|
192
|
+
end
|
193
|
+
|
194
|
+
section "Auth" do
|
195
|
+
add SessionsController::CreateRequest
|
196
|
+
add RegistrationsController::CreateRequest
|
197
|
+
end
|
198
|
+
|
199
|
+
section "Posts" do
|
200
|
+
add PostsController::CreateRequest
|
201
|
+
add PostsController::UpdateRequest
|
202
|
+
add PostsController::DestroyRequest
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
```
|
207
|
+
|
208
|
+
`Explicit::Documentation.build` returns a rails engine that you can mount in
|
209
|
+
your `config/routes.rb`. For example:
|
210
|
+
|
211
|
+
```ruby
|
212
|
+
Rails.application.routes.draw do
|
213
|
+
mount MyApp::API::V1::Documentation => "api/v1/docs"
|
214
|
+
end
|
215
|
+
```
|
216
|
+
|
217
|
+
# Specs
|
218
|
+
|
219
|
+
### Agreement
|
220
|
+
|
221
|
+
```ruby
|
222
|
+
:agreement
|
223
|
+
[:agreement, parse: true]
|
224
|
+
```
|
225
|
+
|
226
|
+
The `agreement` type is a boolean that must always be true. Useful for terms of
|
227
|
+
use or agreement acceptances. If `parse: true` is specified then the following
|
228
|
+
values are accepted alongisde `true`: "`true`", `"on"` and `"1"`.
|
229
|
+
|
230
|
+
### Array
|
231
|
+
|
232
|
+
```ruby
|
233
|
+
[:array, subspec, options = {}]
|
234
|
+
[:array, :string]
|
235
|
+
[:array, :integer, empty: false]
|
236
|
+
```
|
237
|
+
|
238
|
+
All items in the array must be valid according to the subspec. If at least one
|
239
|
+
value is invalid then the array is invalid.
|
240
|
+
|
241
|
+
### Boolean
|
242
|
+
|
243
|
+
```ruby
|
244
|
+
:boolean
|
245
|
+
[:boolean, parse: true]
|
246
|
+
```
|
247
|
+
|
248
|
+
If `parse: true` is specified then the following values are converted to `true`:
|
249
|
+
`"true"`, `"on"` and `"1"`, and the following values are converted to `false`:
|
250
|
+
`"false"`, `"off"` and `"0"`.
|
251
|
+
|
252
|
+
### Date Time ISO8601
|
253
|
+
|
254
|
+
```ruby
|
255
|
+
:date_time_iso8601
|
256
|
+
```
|
257
|
+
|
258
|
+
String encoded date time following the ISO 8601 spec. For example:
|
259
|
+
`"2024-12-10T14:21:00Z"`
|
260
|
+
|
261
|
+
### Date Time Posix
|
262
|
+
|
263
|
+
```ruby
|
264
|
+
:date_time_posix
|
265
|
+
```
|
266
|
+
|
267
|
+
The number of elapsed seconds since January 1, 1970 in timezone UTC. For
|
268
|
+
example: `1733923153`
|
269
|
+
|
270
|
+
### Default
|
271
|
+
|
272
|
+
```ruby
|
273
|
+
[:default, default_value, subspec]
|
274
|
+
[:default, "USD", :string]
|
275
|
+
[:default, 0, :integer]
|
276
|
+
[:default, -> { Time.current.iso8601 }, :date_time_iso8601]
|
277
|
+
```
|
278
|
+
|
279
|
+
Provides a default value for the param if the value is not present or it is
|
280
|
+
`nil`. Other falsy values such as empty string or zero have precedence over
|
281
|
+
the default value.
|
282
|
+
|
283
|
+
If you provide a lambda it will execute in every `validate!` call.
|
284
|
+
|
285
|
+
### Hash
|
286
|
+
|
287
|
+
```ruby
|
288
|
+
[:hash, keyspec, valuespec, options = {}]
|
289
|
+
[:hash, :string, :string]
|
290
|
+
[:hash, :string, :integer]
|
291
|
+
[:hash, :string, :integer, empty: false]
|
292
|
+
[:hash, :string, [:array, :date_time_iso8601]]
|
293
|
+
```
|
294
|
+
|
295
|
+
Hashes are key value pairs where all keys must match keyspec and all values must
|
296
|
+
match valuespec. If you are expecting a hash with a specific set of keys it is
|
297
|
+
best to use a [record](#record) instead.
|
298
|
+
|
299
|
+
### Inclusion
|
300
|
+
|
301
|
+
```ruby
|
302
|
+
[:inclusion, allowed_values]
|
303
|
+
[:inclusion, ["user", "admin"]]
|
304
|
+
[:inclusion, [10, 20, 30, 40, 50]]
|
305
|
+
```
|
306
|
+
|
307
|
+
Value must be present in the set of allowed values.
|
308
|
+
|
309
|
+
### Integer
|
310
|
+
|
311
|
+
```ruby
|
312
|
+
:integer
|
313
|
+
[:integer, parse: true]
|
314
|
+
[:integer, negative: false]
|
315
|
+
[:integer, positive: false]
|
316
|
+
[:integer, min: 0] # inclusive
|
317
|
+
[:integer, max: 10] # inclusive
|
318
|
+
```
|
319
|
+
|
320
|
+
If `parse: true` is specified then integer encoded string values such as "10" or
|
321
|
+
"-2" are automatically converted to integer.
|
322
|
+
|
323
|
+
### Literal
|
324
|
+
|
325
|
+
```ruby
|
326
|
+
[:literal, value]
|
327
|
+
[:literal, 6379]
|
328
|
+
[:literal, "value"]
|
329
|
+
"value" # literal strings can use shorter syntax
|
330
|
+
```
|
331
|
+
|
332
|
+
A literal value behaves similar to inclusion with a single value. Useful for
|
333
|
+
declaring multiple types in `one_of`.
|
334
|
+
|
335
|
+
### Nilable
|
336
|
+
|
337
|
+
```ruby
|
338
|
+
[:nilable, subspec]
|
339
|
+
[:nilable, :string]
|
340
|
+
[:nilable, [:array, :integer]]
|
341
|
+
```
|
342
|
+
|
343
|
+
Value must be `nil` or valid according to the subspec.
|
344
|
+
|
345
|
+
### One of
|
346
|
+
|
347
|
+
```ruby
|
348
|
+
[:one_of, spec1, spec2, ..., specN]
|
349
|
+
[:one_of, :string, :integer]
|
350
|
+
[:one_of, { email: :string }, { phone_number: :string }]
|
351
|
+
```
|
352
|
+
|
353
|
+
Attempts to validate against each spec in order stopping at the first spec that
|
354
|
+
successfully matches the value. If none of the specs match, an error is
|
355
|
+
returned.
|
356
|
+
|
357
|
+
### Record
|
358
|
+
|
359
|
+
```ruby
|
360
|
+
user_spec = {
|
361
|
+
name: :string,
|
362
|
+
email: [:string, format: URI::MailTo::EMAIL_REGEXP]
|
363
|
+
}
|
364
|
+
|
365
|
+
address_spec = {
|
366
|
+
country_name: :string,
|
367
|
+
zipcode: [:string, { format: /\d{6}-\d{3}/, strip: true }]
|
368
|
+
}
|
369
|
+
|
370
|
+
payment_spec = {
|
371
|
+
currency: [:nilable, :string], # use :nilable for optional attribute
|
372
|
+
amount: :bigdecimal
|
373
|
+
}
|
374
|
+
```
|
375
|
+
|
376
|
+
Records are hashes with predefined attributes. Records support recursive
|
377
|
+
definitions, that is, you can have records inside records, array of records,
|
378
|
+
records with array of records, etc.
|
379
|
+
|
380
|
+
### String
|
381
|
+
|
382
|
+
```ruby
|
383
|
+
:string
|
384
|
+
[:string, strip: true]
|
385
|
+
[:string, empty: false]
|
386
|
+
[:string, format: URI::MailTo::EMAIL_REGEXP]
|
387
|
+
[:string, minlength: 8] # inclusive
|
388
|
+
[:string, maxlength: 20] # inclusive
|
389
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
en:
|
2
|
+
explicit:
|
3
|
+
errors:
|
4
|
+
agreement: "must be accepted"
|
5
|
+
array: "invalid item at index(%{index}): %{error}"
|
6
|
+
boolean: "must be a boolean"
|
7
|
+
date_time_iso8601: "must be a valid iso8601 date time"
|
8
|
+
date_time_posix: "must be a valid posix timestamps"
|
9
|
+
hash: "must be a map"
|
10
|
+
hash_key: "invalid key (%{key}): %{error}"
|
11
|
+
hash_value: "invalid value at key (%{key}): %{error}"
|
12
|
+
inclusion: "must be one of: %{values}"
|
13
|
+
integer: "must be an integer"
|
14
|
+
min: "must be bigger than or equal to %{min}"
|
15
|
+
max: "must be smaller than or equal to %{max}"
|
16
|
+
negative: "must not be negative"
|
17
|
+
positive: "must not be positive"
|
18
|
+
literal: "must be %{value}"
|
19
|
+
string: "must be a string"
|
20
|
+
empty: "must not be empty"
|
21
|
+
minlength: "length must be greater than or equal to %{minlength}"
|
22
|
+
maxlength: "length must be smaller than or equal to %{maxlength}"
|
23
|
+
format: "must have format %{regex}"
|
data/config/routes.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Explicit::Documentation
|
4
|
+
class Builder
|
5
|
+
def page_title(page_title)
|
6
|
+
@page_title = page_title
|
7
|
+
end
|
8
|
+
|
9
|
+
def primary_color(primary_color)
|
10
|
+
@primary_color = primary_color
|
11
|
+
end
|
12
|
+
|
13
|
+
def section(name)
|
14
|
+
end
|
15
|
+
|
16
|
+
def add(**opts)
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(request)
|
20
|
+
html = Explicit::ApplicationController.render(
|
21
|
+
partial: "documentation",
|
22
|
+
locals: {
|
23
|
+
page_title: @page_title,
|
24
|
+
primary_color: @primary_color,
|
25
|
+
sections: @sections
|
26
|
+
}
|
27
|
+
)
|
28
|
+
|
29
|
+
[200, {}, [html]]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.build(&block)
|
34
|
+
builder = Builder.new.tap { _1.instance_eval &block }
|
35
|
+
|
36
|
+
::Class.new(::Rails::Engine).tap do |engine|
|
37
|
+
engine.routes.draw { root to: builder }
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Explicit::Request::InvalidParams
|
4
|
+
class Error < ::RuntimeError
|
5
|
+
attr_reader :errors
|
6
|
+
|
7
|
+
def initialize(errors)
|
8
|
+
@errors = errors
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module Handler
|
13
|
+
extend ::ActiveSupport::Concern
|
14
|
+
|
15
|
+
included do
|
16
|
+
rescue_from Error do |err|
|
17
|
+
params = Explicit::Spec::Error.translate(err.errors)
|
18
|
+
|
19
|
+
render json: { error: "invalid_params", params: }, status: 422
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Explicit::Request::InvalidResponseError < ::RuntimeError
|
4
|
+
def initialize(response, error)
|
5
|
+
super <<-TXT
|
6
|
+
Response did not match expected spec.
|
7
|
+
|
8
|
+
Got:
|
9
|
+
|
10
|
+
#{response.inspect}
|
11
|
+
|
12
|
+
Expected:
|
13
|
+
|
14
|
+
#{error.inspect}
|
15
|
+
TXT
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Explicit::Request
|
4
|
+
Route = ::Data.define(:method, :path)
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def get(path) = routes << Route.new(method: :get, path:)
|
8
|
+
def head(path) = routes << Route.new(method: :head, path:)
|
9
|
+
def post(path) = routes << Route.new(method: :post, path:)
|
10
|
+
def put(path) = routes << Route.new(method: :put, path:)
|
11
|
+
def delete(path) = routes << Route.new(method: :delete, path:)
|
12
|
+
def options(path) = routes << Route.new(method: :options, path:)
|
13
|
+
def patch(path) = routes << Route.new(method: :patch, path:)
|
14
|
+
|
15
|
+
def title(text)
|
16
|
+
# TODO
|
17
|
+
end
|
18
|
+
|
19
|
+
def description(markdown)
|
20
|
+
# TODO
|
21
|
+
end
|
22
|
+
|
23
|
+
def header(name, format)
|
24
|
+
raise ArgumentError("duplicated header #{name}") if headers.key?(name)
|
25
|
+
|
26
|
+
headers[name] = format
|
27
|
+
end
|
28
|
+
|
29
|
+
def param(name, format, **options)
|
30
|
+
raise ArgumentError("duplicated param #{name}") if params.key?(name)
|
31
|
+
|
32
|
+
params[name] = format
|
33
|
+
end
|
34
|
+
|
35
|
+
def response(status, format)
|
36
|
+
responses << { status: [:literal, status], data: format }
|
37
|
+
end
|
38
|
+
|
39
|
+
def validate!(values)
|
40
|
+
params_validator = Explicit::Spec.build(params)
|
41
|
+
|
42
|
+
case params_validator.call(values)
|
43
|
+
in [:ok, validated_data] then validated_data
|
44
|
+
in [:error, err] then raise InvalidParams::Error.new(err)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
def routes
|
50
|
+
@routes ||= []
|
51
|
+
end
|
52
|
+
|
53
|
+
def headers
|
54
|
+
@headers ||= {}
|
55
|
+
end
|
56
|
+
|
57
|
+
def params
|
58
|
+
@params ||= {}
|
59
|
+
end
|
60
|
+
|
61
|
+
INVALID_PARAMS_SPEC = {
|
62
|
+
status: [:literal, 422],
|
63
|
+
data: {
|
64
|
+
error: "invalid_params",
|
65
|
+
params: [:hash, :string, :string]
|
66
|
+
}
|
67
|
+
}.freeze
|
68
|
+
|
69
|
+
def responses
|
70
|
+
@responses ||= [INVALID_PARAMS_SPEC]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Explicit::Spec::Agreement
|
4
|
+
extend self
|
5
|
+
|
6
|
+
VALUES = {
|
7
|
+
"true" => true,
|
8
|
+
"on" => true,
|
9
|
+
"1" => true
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
def build(options)
|
13
|
+
lambda do |value|
|
14
|
+
value =
|
15
|
+
if value.is_a?(TrueClass)
|
16
|
+
value
|
17
|
+
elsif value.is_a?(::String) && options[:parse]
|
18
|
+
VALUES[value]
|
19
|
+
else
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
|
23
|
+
return [:error, :agreement] if value.nil?
|
24
|
+
|
25
|
+
[:ok, value]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|