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 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,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Explicit
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Explicit
4
+ module ApplicationHelper
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= local_assigns[:page_title] || "API Documentation" %></title>
5
+ </head>
6
+
7
+ <body>
8
+ <h1>Hello world</h1>
9
+ </body>
10
+ </html>
@@ -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,2 @@
1
+ Explicit::Engine.routes.draw do
2
+ end
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Explicit::Engine < ::Rails::Engine
4
+ initializer "explicit.rescue_from_invalid_params" do
5
+ ActiveSupport.on_load(:action_controller_api) do
6
+ include Explicit::Request::InvalidParams::Handler
7
+ end
8
+ end
9
+ 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