tomyum 0.1.0.a
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +1106 -0
- data/lib/tomyum/assertions.rb +80 -0
- data/lib/tomyum/attributes/array.rb +11 -0
- data/lib/tomyum/attributes/attribute.rb +130 -0
- data/lib/tomyum/attributes/boolean.rb +22 -0
- data/lib/tomyum/attributes/currency.rb +19 -0
- data/lib/tomyum/attributes/date.rb +11 -0
- data/lib/tomyum/attributes/float.rb +13 -0
- data/lib/tomyum/attributes/integer.rb +14 -0
- data/lib/tomyum/attributes/ip_address.rb +15 -0
- data/lib/tomyum/attributes/number.rb +24 -0
- data/lib/tomyum/attributes/object.rb +71 -0
- data/lib/tomyum/attributes/schema.rb +23 -0
- data/lib/tomyum/attributes/string.rb +36 -0
- data/lib/tomyum/attributes/time.rb +19 -0
- data/lib/tomyum/attributes/uri.rb +19 -0
- data/lib/tomyum/attributes/visitor.rb +136 -0
- data/lib/tomyum/attributes.rb +92 -0
- data/lib/tomyum/endpoint.rb +102 -0
- data/lib/tomyum/endpoints/method.rb +90 -0
- data/lib/tomyum/endpoints/params.rb +115 -0
- data/lib/tomyum/error.rb +17 -0
- data/lib/tomyum/functions.rb +49 -0
- data/lib/tomyum/generators/generator.rb +16 -0
- data/lib/tomyum/generators/grpc/generator.rb +10 -0
- data/lib/tomyum/generators/open_api/generator.rb +205 -0
- data/lib/tomyum/generators/open_api/property_generator.rb +111 -0
- data/lib/tomyum/generators.rb +3 -0
- data/lib/tomyum/registry.rb +75 -0
- data/lib/tomyum/resolvable.rb +11 -0
- data/lib/tomyum/resolver.rb +99 -0
- data/lib/tomyum/serializer.rb +125 -0
- data/lib/tomyum/serializers/serializable.rb +23 -0
- data/lib/tomyum/server/app.rb +33 -0
- data/lib/tomyum/server/document.rb +20 -0
- data/lib/tomyum/server/documents/redoc.rb +36 -0
- data/lib/tomyum/server/documents/swagger.rb +47 -0
- data/lib/tomyum/server/routes.rb +0 -0
- data/lib/tomyum/support.rb +13 -0
- data/lib/tomyum/validator.rb +205 -0
- data/lib/tomyum/validators/normalizable.rb +24 -0
- data/lib/tomyum/validators/proxy.rb +77 -0
- data/lib/tomyum/validators/validatable.rb +48 -0
- data/lib/tomyum/version.rb +3 -0
- data/lib/tomyum.rb +28 -0
- metadata +202 -0
data/README.md
ADDED
@@ -0,0 +1,1106 @@
|
|
1
|
+
# Tomyum
|
2
|
+
|
3
|
+
<img alt="GitHub Actions status" src="https://github.com/rezigned/tomyum/workflows/Ruby/badge.svg">
|
4
|
+
|
5
|
+
`Tomyum` is a DSL for defining API [Schema](#schema)/[Endpoint](#endpoint) with the capabilities to generate `Open API (v3)` spec (other specs is coming soon), validate API request and serialize object for API response based on single schema definition.
|
6
|
+
|
7
|
+
## Table of Contents
|
8
|
+
|
9
|
+
- [Quick Start](#quick-start)
|
10
|
+
- [Defining a Schema](#defining-a-schema)
|
11
|
+
- [Serializing Schema](#serializing-schema)
|
12
|
+
- [Generating Open API (v3)](#generating-open-api-v3)
|
13
|
+
- [Installation](#installation)
|
14
|
+
- [Attributes](#attributes)
|
15
|
+
- [Creating Attribute](#creating-attribute)
|
16
|
+
- [Attribute Options](#attribute-options)
|
17
|
+
- [Custom Attribute](#custom-attribute)
|
18
|
+
- [Resolver](#resolver)
|
19
|
+
- [Manually registering schema/endpoint](#manually-registering-schemaendpoint)
|
20
|
+
- [Automatically registering schema/endpoint](#automatically-registering-schemaendpoint)
|
21
|
+
- [Serialization](#serialization)
|
22
|
+
- [Example](#serializer-example)
|
23
|
+
- [Conditional Serialization](#conditional-serialization)
|
24
|
+
- [Custom Serializer](#custom-serializer)
|
25
|
+
- [Endpoint](#endpoint)
|
26
|
+
- [Method](#endpoint-method)
|
27
|
+
- [Params](#endpoint-params)
|
28
|
+
- [Validation](#endpoint-validation)
|
29
|
+
- [Permitted Params (Rails)](#permitted-params)
|
30
|
+
- [Validation](#validation)
|
31
|
+
- [Conditional Validation](#validator-condition)
|
32
|
+
- [Custom Normalizer](#custom-normalizer)
|
33
|
+
- [Custom Validator](#custom-validator)
|
34
|
+
- [Generating API Specifications](#generating-api-specification)
|
35
|
+
- [Open API (v3)](#open-api-v3)
|
36
|
+
|
37
|
+
|
38
|
+
## Quick Start
|
39
|
+
|
40
|
+
### Defining a schema
|
41
|
+
|
42
|
+
Let's start by creating a simple schema. (For a complete list of attributes and its options please see [Attributes](#attributes) section)
|
43
|
+
|
44
|
+
> A schema can be created and registered separately, or we can use `Resolver` to create and register in one step.
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
require "tomyum"
|
48
|
+
|
49
|
+
# Schema is just an `Object`
|
50
|
+
Tomyum::Resolver.new do
|
51
|
+
|
52
|
+
# Defines :hero schema
|
53
|
+
schema :hero do
|
54
|
+
string :name, min: 4
|
55
|
+
|
56
|
+
# recursive schema
|
57
|
+
array :friends, of: :hero
|
58
|
+
|
59
|
+
# nested object
|
60
|
+
object :stats do
|
61
|
+
number :strength, default: 0
|
62
|
+
end
|
63
|
+
|
64
|
+
# reference
|
65
|
+
schema :team
|
66
|
+
end
|
67
|
+
|
68
|
+
# Defines :team schema
|
69
|
+
schema :team do
|
70
|
+
string :name, default: "Avengers"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
### Serializing schema
|
76
|
+
|
77
|
+
In order to serialize schema, we just pass schema defined in previous step to `Serializer`.
|
78
|
+
|
79
|
+
> The object to be serialized can be a `Hash` or any `Object` with method/accessor.
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
hero = {
|
83
|
+
name: "Thor",
|
84
|
+
friends: [
|
85
|
+
{
|
86
|
+
name: "Iron Man",
|
87
|
+
stats: {
|
88
|
+
strength: "9"
|
89
|
+
}
|
90
|
+
}
|
91
|
+
],
|
92
|
+
stats: {
|
93
|
+
strength: "8"
|
94
|
+
}
|
95
|
+
}
|
96
|
+
|
97
|
+
# or using our own Model class
|
98
|
+
hero = Hero.new(name: "Thor")
|
99
|
+
|
100
|
+
serializer = Tomyum::Serializer.new(resolver)
|
101
|
+
serializer.serialize(hero_schema, hero)
|
102
|
+
```
|
103
|
+
|
104
|
+
will produce the following result
|
105
|
+
|
106
|
+
```ruby
|
107
|
+
{
|
108
|
+
name: "Thor",
|
109
|
+
stats: {
|
110
|
+
strength: 8
|
111
|
+
},
|
112
|
+
friends: [
|
113
|
+
{ name: "Iron Man", stats: { strength: 9 }, friends: [], team: { name: "Avengers" } }
|
114
|
+
],
|
115
|
+
team: {
|
116
|
+
name: "Avengers"
|
117
|
+
}
|
118
|
+
}
|
119
|
+
```
|
120
|
+
> Note: It converts number to string (via `Attribute.serializer`) and automatically adds missing fields and default value
|
121
|
+
|
122
|
+
### Validating data
|
123
|
+
|
124
|
+
To validate data against defined schema. We just pass it to `Validator#validate` method.
|
125
|
+
|
126
|
+
> The payload could be a `Hash` or any `Object`.
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
payload = {
|
130
|
+
name: "Hulk",
|
131
|
+
friends: [
|
132
|
+
{ name: "Tony" },
|
133
|
+
{ name: "Sta"},
|
134
|
+
],
|
135
|
+
team: {
|
136
|
+
name: "Aven"
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
validator = Tomyum::Validator.new(resolver)
|
141
|
+
validator.validate(user_schema, payload) # => Result
|
142
|
+
```
|
143
|
+
|
144
|
+
It'll return `Result` object which has 2 attributes `#value` and `#errors`.
|
145
|
+
|
146
|
+
In the case of errors, `#errors` will contain all error messages
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
# Result.errors =>
|
150
|
+
{
|
151
|
+
friends: {
|
152
|
+
1 => {
|
153
|
+
name: ["is too short (minimum is 4 characters)"]
|
154
|
+
}
|
155
|
+
},
|
156
|
+
team: {
|
157
|
+
name: ["is too short (minimum is 5 characters)"]
|
158
|
+
}
|
159
|
+
}
|
160
|
+
```
|
161
|
+
|
162
|
+
If there's no errors, `#value` will contain normalized data.
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
# Result.value =>
|
166
|
+
{
|
167
|
+
name: "Thor",
|
168
|
+
stats: {
|
169
|
+
strength: 0
|
170
|
+
},
|
171
|
+
friends: [
|
172
|
+
{
|
173
|
+
name: "Iron Man",
|
174
|
+
stats: { strength: 0 },
|
175
|
+
friends: [],
|
176
|
+
team: {
|
177
|
+
name: "Avengers"
|
178
|
+
}
|
179
|
+
}
|
180
|
+
],
|
181
|
+
team: { name: "Avengers" }
|
182
|
+
}
|
183
|
+
```
|
184
|
+
|
185
|
+
> All default values will be added automatically.
|
186
|
+
|
187
|
+
### Generating Open API (v3)
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
generator = Tomyum::Generators::OpenAPI::Generator.new(resolver)
|
191
|
+
generator.generate # => return Open API v3 Specification
|
192
|
+
```
|
193
|
+
|
194
|
+
## Installation
|
195
|
+
|
196
|
+
gem 'tomyum'
|
197
|
+
|
198
|
+
And then execute:
|
199
|
+
|
200
|
+
$ bundle
|
201
|
+
|
202
|
+
Or install it yourself as:
|
203
|
+
|
204
|
+
$ gem install tomyum
|
205
|
+
|
206
|
+
## Attributes
|
207
|
+
|
208
|
+
`Attribute` is just a simple class which doesn't have any functionality/logic on its own. With this design, it decouples the logics to `validate`, `serialize`, etc from `Attribute` and let's consumers (e.g. `Validator`, `Serializer`, etc) decide it instead.
|
209
|
+
|
210
|
+
Here're pre-defined attributes that we can use.
|
211
|
+
|
212
|
+
* `string` - String
|
213
|
+
* `boolean` - Boolean
|
214
|
+
* `number` - Number
|
215
|
+
* `integer` - Integer
|
216
|
+
* `float` - Float
|
217
|
+
* `array` - [Array](#array-attribute)
|
218
|
+
* `object` - [Object](#object-attribute)
|
219
|
+
* `schema` - [Schema](#schema-attribute)
|
220
|
+
|
221
|
+
### Creating Attribute
|
222
|
+
|
223
|
+
There're couple of ways to create attributes. We can create it manually or using some other methods.
|
224
|
+
|
225
|
+
#### Manually Creating Attribute
|
226
|
+
Here, we're creating it manually.
|
227
|
+
|
228
|
+
```ruby
|
229
|
+
user_schema = Tomyum::Attributes::Object.new(:user) do
|
230
|
+
string :username
|
231
|
+
string :password
|
232
|
+
end
|
233
|
+
```
|
234
|
+
|
235
|
+
#### Using `Attributes.create`
|
236
|
+
|
237
|
+
This is equivalent to the above code
|
238
|
+
|
239
|
+
```ruby
|
240
|
+
Tomyum::Attributes.create(:object, :user) do
|
241
|
+
string :username
|
242
|
+
string :password
|
243
|
+
end
|
244
|
+
```
|
245
|
+
|
246
|
+
#### Using `Resolver`
|
247
|
+
|
248
|
+
This is probably the easiest way to create object attribute. Since it also keeps track of the created attribute for you. (So, we don't have to register it by ourself. See also [Resolver](#resolver))
|
249
|
+
|
250
|
+
```ruby
|
251
|
+
Tomyum::Resolver.new do
|
252
|
+
schema :user do
|
253
|
+
string :username
|
254
|
+
string :password
|
255
|
+
end
|
256
|
+
end
|
257
|
+
```
|
258
|
+
|
259
|
+
### Array Attribute
|
260
|
+
|
261
|
+
Array attribute supports an option named `:of` which we can use to describe what kind of data can be contained inside `array`.
|
262
|
+
|
263
|
+
For example
|
264
|
+
|
265
|
+
```ruby
|
266
|
+
schema :user do
|
267
|
+
string :name
|
268
|
+
|
269
|
+
# Indicates that each item inside must have the same structure
|
270
|
+
# as :user schema (e.g. friends: [{ name: "a", friends: []}, ...])
|
271
|
+
array :friends, of: :user
|
272
|
+
|
273
|
+
# i.e. pets: ["Joey", "Buddy", ...]
|
274
|
+
array :pets, of: :string
|
275
|
+
|
276
|
+
# When nothing is given, array can contain any kind of data
|
277
|
+
array :others
|
278
|
+
end
|
279
|
+
```
|
280
|
+
|
281
|
+
### Object Attribute
|
282
|
+
|
283
|
+
Most of the time, we'll be working with this attribute more often.
|
284
|
+
|
285
|
+
```ruby
|
286
|
+
schema :user do
|
287
|
+
string :name
|
288
|
+
|
289
|
+
# nested attribute
|
290
|
+
object :address do
|
291
|
+
string :street
|
292
|
+
|
293
|
+
# more nested attribute if needed
|
294
|
+
object :country do
|
295
|
+
string :name
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
```
|
300
|
+
|
301
|
+
### Schema Attribute
|
302
|
+
|
303
|
+
Instead of declaring a lot of nested object. We can also use `schema` attribute to refer to other previously defined schema (DRY).
|
304
|
+
|
305
|
+
It also accepts `:of` option. (it works the same as `Array`)
|
306
|
+
|
307
|
+
```ruby
|
308
|
+
schema :hero do
|
309
|
+
string :name
|
310
|
+
|
311
|
+
# This implies `of: :team`
|
312
|
+
schema :team
|
313
|
+
|
314
|
+
# If the field name is different, we can specify `:of` option (which work the same way as `Array`)
|
315
|
+
schema :awesome_team, of: :team
|
316
|
+
end
|
317
|
+
|
318
|
+
schema :team do
|
319
|
+
string :name
|
320
|
+
end
|
321
|
+
```
|
322
|
+
|
323
|
+
### Attribute Options
|
324
|
+
|
325
|
+
All attributes have common set of options avaiable
|
326
|
+
|
327
|
+
| Name | type | Description | Serializer | Validator | Open API
|
328
|
+
|-|-|-|-|-|-|
|
329
|
+
| name | `string\|symbol` | Attribute's name e.g. `email` |
|
330
|
+
| [method](#method-options) | `symbol` | When attribute's `name` differs from target's `name`, we can use this to specify a method that will be used to get the value for the attribute. | read value from specified name instead. See [Serializer `:method`](#serializer-options-method). | | |
|
331
|
+
| [match](#match-options) | `Regexp` | `Regex` that will be used to validate the attribute's value (`string` attribute) | | validates string based on given `Regexp` | Will add `Regexp` to generated `pattern` property |
|
332
|
+
| type | `string` | Data type | Attribute's type e.g. `string`, `schema` etc
|
333
|
+
| native_type | `string` | JSON's data type |
|
334
|
+
| [format](#format-options) | `string\|symbol ` | describes the format of this value. Could be arbitary value e.g. `int64`, `email` etc. | | | This'll be added to `format` property |
|
335
|
+
| [of](#of-options) | `symbol ` | specifies what type of data will be represented in `array`. The value could be attribute type e.g. `:string` or other schema e.g. `:user` | serializes data using specified value. See [Serializer `:of`](#serializer-options-of)| validates data using specified value | Create a `$ref` type schema |
|
336
|
+
| [in](#in-options) | `array` | A set of valid options e.g. `%w[thb usd ...]` | | payload value must be in this list | adds to `enum` property |
|
337
|
+
| [if/unless](#if-options) | `symbol\|proc` | Conditional validating/serializing result | serializes only when the specified value evalulates to `true`. See [Serializer `:if/:unless`](#serializer-options-if-unless) | validates only when it evalulates to `true` |
|
338
|
+
| required | `boolean` | Indicates this attribute is required (for validation). Default is `false` | | makes it mandatory | adds to `required` list |
|
339
|
+
| default | `any` | Default value for attribute | uses as default value when target's value is `nil` | populates as default value when it's not defined in payload | adds to `default` property |
|
340
|
+
| private | `boolean` | marks it as `private`. Default is `false` | Excludes this attribute from serialized value |
|
341
|
+
| deprecated | `boolean` | marks this attribute as deprecated. Default is `false` | | | adds to `deprecated` property |
|
342
|
+
| description | `string` | Description of attribute |
|
343
|
+
| spec | `string` | Specification of the value (for referencing only) |
|
344
|
+
| spec_uri | `string` | URI of `spec` |
|
345
|
+
|
346
|
+
### Custom Attribute
|
347
|
+
|
348
|
+
If you want to add your own custom attribute. Simply create a new class and make it extends from `Attribute` and then register it to `Attributes` registry.
|
349
|
+
|
350
|
+
```ruby
|
351
|
+
class AwesomeAttribute < Tomyum::Attributes::Attribute
|
352
|
+
# codes ...
|
353
|
+
end
|
354
|
+
|
355
|
+
# Register it
|
356
|
+
Tomyum::Attributes.register(:awesome, AwesomeAttribute)
|
357
|
+
```
|
358
|
+
|
359
|
+
Later, we can refer to it like the following.
|
360
|
+
|
361
|
+
```ruby
|
362
|
+
schema :user do
|
363
|
+
string :email
|
364
|
+
awesome :cool # our custom attribute
|
365
|
+
end
|
366
|
+
```
|
367
|
+
|
368
|
+
## Resolver
|
369
|
+
|
370
|
+
`Resolver` acts as a registry that holds references to all `schemas` and `endpoints` that we've defined.
|
371
|
+
|
372
|
+
### Manually registering schema/endpoint
|
373
|
+
|
374
|
+
```ruby
|
375
|
+
user_schema = create_user_schema
|
376
|
+
|
377
|
+
# pass it to the constructor
|
378
|
+
resolver = Tomyum::Resolver.new(schemas: [user_schema], endpoints: [])
|
379
|
+
|
380
|
+
# or use `#schemas` method
|
381
|
+
resolver.schemas.register(:user, user_schema)
|
382
|
+
```
|
383
|
+
|
384
|
+
### Automatically registering schema/endpoint
|
385
|
+
|
386
|
+
Although we can create a schema and register it manually. It'll become a tedious tasks when there're lot of schemas/endpoints to work with.
|
387
|
+
|
388
|
+
```ruby
|
389
|
+
resolver = Tomyum::Resolver.new do
|
390
|
+
|
391
|
+
# schema is just `Object`
|
392
|
+
schema :user do
|
393
|
+
# codes
|
394
|
+
end
|
395
|
+
|
396
|
+
schema :group do
|
397
|
+
# codes
|
398
|
+
end
|
399
|
+
|
400
|
+
endpoint "/users" do
|
401
|
+
# codes
|
402
|
+
end
|
403
|
+
end
|
404
|
+
```
|
405
|
+
|
406
|
+
## Serialization
|
407
|
+
|
408
|
+
`Serializer` was designed in the way that it can serialize any type of `Attribute`. Either it's simple attribute type such as `String` or a more complex type like `Object`. The `Serializer` treats it the same way.
|
409
|
+
|
410
|
+
### <a name="serializer-example"></a> Example
|
411
|
+
#### Serializing a simple type
|
412
|
+
|
413
|
+
```ruby
|
414
|
+
attr = Tomyum::Attributes.create(:string, :email)
|
415
|
+
|
416
|
+
serializer.serialize(attr, 3.14) # => "3.14"
|
417
|
+
```
|
418
|
+
|
419
|
+
#### Serializing `Array`
|
420
|
+
|
421
|
+
```ruby
|
422
|
+
attr = Tomyum::Attributes.create(:array, :heroes)
|
423
|
+
|
424
|
+
serializer.serialize(attr, ["Iron Man", "Thor"]) # => ["Iron Man", "Thor"]
|
425
|
+
```
|
426
|
+
|
427
|
+
#### Serializing `Object`
|
428
|
+
|
429
|
+
When serializing object, the data that we want to serialize can be a `Hash` or plain `Object`.
|
430
|
+
|
431
|
+
In the following example. We serialize an instance of `Hero` class.
|
432
|
+
|
433
|
+
```ruby
|
434
|
+
attr = Tomyum::Attributes.create(:object, :hero) do
|
435
|
+
string :name
|
436
|
+
number :power, default: 5
|
437
|
+
end
|
438
|
+
|
439
|
+
# Defines our class
|
440
|
+
class Hero
|
441
|
+
attr_reader :name, :power
|
442
|
+
|
443
|
+
def initialize(name:, power: nil)
|
444
|
+
@name = name
|
445
|
+
@power = power
|
446
|
+
end
|
447
|
+
|
448
|
+
def name
|
449
|
+
"I'm #{@name}"
|
450
|
+
end
|
451
|
+
end
|
452
|
+
|
453
|
+
thor = Hero.new(name: "Thor")
|
454
|
+
|
455
|
+
# Serializes
|
456
|
+
serializer.serialize(attr, thor) # =>
|
457
|
+
|
458
|
+
{
|
459
|
+
name: "I'm Thor",
|
460
|
+
power: 5
|
461
|
+
}
|
462
|
+
```
|
463
|
+
|
464
|
+
### <a name="serializer-options"></a> Options
|
465
|
+
#### <a name="serializer-options-method"></a> `:method`
|
466
|
+
|
467
|
+
*Value: Symbol* - Alternative method name
|
468
|
+
|
469
|
+
We can use this option to specify which method of the target object that will be used to get the data from.
|
470
|
+
|
471
|
+
```ruby
|
472
|
+
# Our schema
|
473
|
+
schema :hero do
|
474
|
+
string :name, method: :awesome_name
|
475
|
+
end
|
476
|
+
|
477
|
+
class Hero
|
478
|
+
def initialize(name)
|
479
|
+
@name = name
|
480
|
+
end
|
481
|
+
|
482
|
+
def awesome_name
|
483
|
+
"I'm super #{@name}!"
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
hero = Hero.new(name: "Thor")
|
488
|
+
|
489
|
+
# Serializes
|
490
|
+
serializer.serialize(hero_schema, hero)
|
491
|
+
```
|
492
|
+
|
493
|
+
will produce the following result.
|
494
|
+
```
|
495
|
+
{
|
496
|
+
"name": "I'm super Thor!"
|
497
|
+
}
|
498
|
+
```
|
499
|
+
|
500
|
+
#### <a name="serializer-options-of"></a> `:of`
|
501
|
+
|
502
|
+
*Value: `Symbol`* - Attribute type or Schema name e.g. `:string` or `:user`
|
503
|
+
|
504
|
+
This option allows us to refer to other schema from `array` or `schema` attribute.
|
505
|
+
|
506
|
+
In the following example. We'll re-use our previously defined `:hero` schema with our new `:team` schema.
|
507
|
+
|
508
|
+
```ruby
|
509
|
+
schema :team do
|
510
|
+
array :heroes, of: :hero
|
511
|
+
end
|
512
|
+
|
513
|
+
class Team
|
514
|
+
attr_reader :name, :heroes
|
515
|
+
|
516
|
+
def initialize(name: name, heroes: heroes)
|
517
|
+
@name = name
|
518
|
+
@heroes = heroes
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
team = Team.new(name: "Avengers", heroes: [
|
523
|
+
Hero.new(name: "Thor"),
|
524
|
+
Hero.new(name: "Hulk", power: 10),
|
525
|
+
])
|
526
|
+
|
527
|
+
# Serializes
|
528
|
+
serializer.serialize(team_schema, team)
|
529
|
+
```
|
530
|
+
|
531
|
+
will produce the following result
|
532
|
+
|
533
|
+
```ruby
|
534
|
+
{
|
535
|
+
name: "Avengers",
|
536
|
+
heroes: [
|
537
|
+
{ name: "Thor", power: 5 }, # 5 is default value from hero schema
|
538
|
+
{ name: "Hulk", power: 10 },
|
539
|
+
]
|
540
|
+
}
|
541
|
+
```
|
542
|
+
|
543
|
+
#### <a name="serializer-options-private"></a> `:private`
|
544
|
+
|
545
|
+
*Value: `Boolean`* - Default is `false`
|
546
|
+
|
547
|
+
When it's set to `true`. It'll exclude that attribute from serializer's result.
|
548
|
+
|
549
|
+
```ruby
|
550
|
+
schema :hero do
|
551
|
+
string :name
|
552
|
+
number :secret_power, private: true
|
553
|
+
end
|
554
|
+
|
555
|
+
hero = Hero.new(name: "Peter", secret_power: 99)
|
556
|
+
serializer.serialize(hero_schema, hero)
|
557
|
+
```
|
558
|
+
|
559
|
+
will exclude `secret_power` from result
|
560
|
+
|
561
|
+
```ruby
|
562
|
+
{
|
563
|
+
name: "Peter"
|
564
|
+
}
|
565
|
+
```
|
566
|
+
|
567
|
+
### Conditional Serialization
|
568
|
+
#### <a name="serializer-options-private"></a> `:if/:unless`
|
569
|
+
|
570
|
+
*Possible value: `Symbol`/`Proc`*
|
571
|
+
|
572
|
+
This enables us to serialize value based on `if/unless` conditions.
|
573
|
+
|
574
|
+
```ruby
|
575
|
+
schema :hero do
|
576
|
+
string :name
|
577
|
+
|
578
|
+
# :mode'll get serialized only when `dog_dead` is true
|
579
|
+
string :mode, if: :dog_dead
|
580
|
+
|
581
|
+
# or we can use `Proc` e.g.
|
582
|
+
# string :mode, if: ->(x) { x.dog_dead }
|
583
|
+
boolean :dog_dead, default: false
|
584
|
+
end
|
585
|
+
|
586
|
+
hero = Hero.new(name: "John Wick", mode: "Angry")
|
587
|
+
serializer.serialize(hero_schema, hero) # =>
|
588
|
+
```
|
589
|
+
|
590
|
+
will produce
|
591
|
+
|
592
|
+
```ruby
|
593
|
+
{
|
594
|
+
name: "John Wick",
|
595
|
+
dog_dead: false
|
596
|
+
}
|
597
|
+
```
|
598
|
+
But if we set `dog_dead: true` the result will include `mode` value.
|
599
|
+
|
600
|
+
```ruby
|
601
|
+
{
|
602
|
+
name: "John Wick",
|
603
|
+
mode: "Angry",
|
604
|
+
dog_dead: true,
|
605
|
+
}
|
606
|
+
```
|
607
|
+
|
608
|
+
### Custom Serializer
|
609
|
+
|
610
|
+
By default, each primitive types e.g. `string`, `integer`, etc. has its own serializer. We can override it by overriding `.serializer` class method.
|
611
|
+
|
612
|
+
For example. If we want to extend `boolean` attribute to treat "**on**" as a valid boolean value. We could do it like this.
|
613
|
+
|
614
|
+
```ruby
|
615
|
+
Boolean.normalize = ->(x) { ["on", true].include?(x) ? true : false }
|
616
|
+
|
617
|
+
# Usage
|
618
|
+
schema :team do
|
619
|
+
boolean :active
|
620
|
+
end
|
621
|
+
|
622
|
+
team = Team.new(active: "on")
|
623
|
+
serializer.serialize(team_schema, team)
|
624
|
+
```
|
625
|
+
|
626
|
+
will produce
|
627
|
+
|
628
|
+
```ruby
|
629
|
+
{
|
630
|
+
active: true
|
631
|
+
}
|
632
|
+
```
|
633
|
+
|
634
|
+
## Endpoint
|
635
|
+
|
636
|
+
Endpoint represents the API endpoint and it's operations e.g. `GET`, `POST`, etc.
|
637
|
+
It groups resource's related operations together and defines a set of valid parameters that the endpoint accepts.
|
638
|
+
|
639
|
+
### Defining Endpoint
|
640
|
+
|
641
|
+
We could define it manually as follows
|
642
|
+
|
643
|
+
```ruby
|
644
|
+
Tomyum::Endpoint.new "/users" do
|
645
|
+
get :read, path: "/:id"
|
646
|
+
end
|
647
|
+
```
|
648
|
+
|
649
|
+
or defines it via `Resolver`
|
650
|
+
|
651
|
+
```ruby
|
652
|
+
Tomyum::Resolver.new do
|
653
|
+
endpoint "/heroes" do
|
654
|
+
post :create, path: "/"
|
655
|
+
end
|
656
|
+
|
657
|
+
endpoint "/teams" do
|
658
|
+
# code
|
659
|
+
end
|
660
|
+
end
|
661
|
+
```
|
662
|
+
|
663
|
+
### <a name="endpoint-method"></a> Method
|
664
|
+
|
665
|
+
Endpoint provides a set of http methods such as `get`, `post`, `patch`, `delete`, etc.
|
666
|
+
Each method expects a name and some other options.
|
667
|
+
|
668
|
+
> Note that name must be unique within endpoint
|
669
|
+
|
670
|
+
```ruby
|
671
|
+
method(name, path:, params:)
|
672
|
+
|
673
|
+
# e.g.
|
674
|
+
get :read, path: "/:id"
|
675
|
+
post :create, path: "/"
|
676
|
+
```
|
677
|
+
|
678
|
+
### <a name="endpoint-params"></a> Params
|
679
|
+
|
680
|
+
`Params` allows us to define a set of valid parameters accepted by the Endpoint's methods (e.g. `get`, `post`, etc).
|
681
|
+
|
682
|
+
We can think of `Params` the same way as `Schema` (i.e. `Object`). It's just a specialize version of `Object`.
|
683
|
+
|
684
|
+
#### Defining default params
|
685
|
+
|
686
|
+
```ruby
|
687
|
+
endpoint "/users" do
|
688
|
+
# declare default params
|
689
|
+
params do
|
690
|
+
string :username
|
691
|
+
string :password
|
692
|
+
end
|
693
|
+
|
694
|
+
# `params: true` will use default params for validating incoming request
|
695
|
+
post :create, params: true
|
696
|
+
|
697
|
+
# this will re-use `username` param from default params
|
698
|
+
patch :update, params: %i[username]
|
699
|
+
end
|
700
|
+
```
|
701
|
+
|
702
|
+
#### Using no params `params: false` (default behaviour)
|
703
|
+
|
704
|
+
If we don't want our endpoint's method to re-use default params. We can specify `params: false` to endpoint method's arguments.
|
705
|
+
|
706
|
+
> Note: this is the default behaviour. So we can leave it blank.
|
707
|
+
|
708
|
+
```ruby
|
709
|
+
get :index, path: "/" # ignore `params` means `params: false`
|
710
|
+
```
|
711
|
+
|
712
|
+
#### Using default params `params: true`
|
713
|
+
|
714
|
+
As we've seen in the example above. Set `params: true` indicates that we want to use default params for this method.
|
715
|
+
|
716
|
+
```ruby
|
717
|
+
post :create, path: "/", params: true
|
718
|
+
```
|
719
|
+
|
720
|
+
#### Using subset of default params's attributes `params: %i[...]`
|
721
|
+
|
722
|
+
Let's say we want to use only some of default params's attrbiute e.g. `currency` (but not other attributes). We can specify it like this.
|
723
|
+
|
724
|
+
```ruby
|
725
|
+
patch :update, path: "/:id", params: %i[currency]
|
726
|
+
```
|
727
|
+
|
728
|
+
In this case it will re-use only `currency` attribute for validation.
|
729
|
+
|
730
|
+
#### Excluding some of default params's attributes `exclude: true`
|
731
|
+
|
732
|
+
Let's say the `update` method doesn't need the `default params`'s `amount` attribute (but still want to use all other attributes). We can specify it as follows.
|
733
|
+
|
734
|
+
```ruby
|
735
|
+
patch :update, path: "/:id", params: %i[amount], exclude: true
|
736
|
+
```
|
737
|
+
|
738
|
+
#### Overriding default params with `params: Proc`
|
739
|
+
|
740
|
+
In the case where we want to completely use the new set of params. We can use `Proc` to define it like the following.
|
741
|
+
|
742
|
+
```ruby
|
743
|
+
# inside schema.endpoint block
|
744
|
+
patch :update, path: "/:id", params: lambda {
|
745
|
+
integer :max_amount, required: true
|
746
|
+
string :new_currency, match: /\A[a-z]{3}\z/
|
747
|
+
}
|
748
|
+
```
|
749
|
+
|
750
|
+
It will use these new params for validating/generating specs.
|
751
|
+
|
752
|
+
#### Re-using Params from Other Service `params: Symbol`
|
753
|
+
|
754
|
+
We can also re-use params from other endpoint by specifing a `Symbol` that refers to other endpoint's params.
|
755
|
+
|
756
|
+
```ruby
|
757
|
+
endpoint "/search" do
|
758
|
+
params do
|
759
|
+
string :query
|
760
|
+
number :limit
|
761
|
+
end
|
762
|
+
end
|
763
|
+
|
764
|
+
endpoint "/blogs" do
|
765
|
+
get :index, path: "/", params: :search
|
766
|
+
end
|
767
|
+
```
|
768
|
+
|
769
|
+
Here we will use `search` endpoint's default params for validating `GET /users` endpoint.
|
770
|
+
|
771
|
+
### Endpoint Validation
|
772
|
+
|
773
|
+
See [Validation - Validating Endpoint](#validating-endpoint)
|
774
|
+
|
775
|
+
## Validation
|
776
|
+
|
777
|
+
Once we have our schema/endpoint defined. We can use validator to validate it against any data. (it uses `ActiveModel::Validations` under the hood)
|
778
|
+
|
779
|
+
Similar to `Serializer`. We can use `Validator` to validate any type of `Attribute`.
|
780
|
+
|
781
|
+
### Example
|
782
|
+
#### Validating simple attribute
|
783
|
+
|
784
|
+
```ruby
|
785
|
+
attr = Tomyum::Attributes.create(:string, :email)
|
786
|
+
email = true
|
787
|
+
|
788
|
+
validator.validate(attr, email) # => Result object
|
789
|
+
```
|
790
|
+
|
791
|
+
In this case, it'll just return a `Result.errors` which contain error message
|
792
|
+
|
793
|
+
```ruby
|
794
|
+
["is not string"]
|
795
|
+
```
|
796
|
+
|
797
|
+
#### Validating `Array`
|
798
|
+
|
799
|
+
```ruby
|
800
|
+
attr = Tomyum::Attributes.create(:array, :names, of: :string)
|
801
|
+
names = ["john", 1, "doe"]
|
802
|
+
|
803
|
+
validator.validate(attr, names) # => Result object
|
804
|
+
```
|
805
|
+
|
806
|
+
It'll return the error messages at exact index position.
|
807
|
+
|
808
|
+
```ruby
|
809
|
+
{
|
810
|
+
1 => ["is not string"]
|
811
|
+
}
|
812
|
+
```
|
813
|
+
|
814
|
+
#### Validating `Object`
|
815
|
+
Most of the time, we'll work with the object type (instead of simple type like `string`, etc).
|
816
|
+
```ruby
|
817
|
+
attr = Tomyum::Attributes.create(:object, :user) do
|
818
|
+
string :username, min: 4
|
819
|
+
string :password, min: 10
|
820
|
+
end
|
821
|
+
|
822
|
+
user = {
|
823
|
+
username: "john",
|
824
|
+
password: true,
|
825
|
+
}
|
826
|
+
|
827
|
+
validator.validate(attr, user) # => Result object
|
828
|
+
```
|
829
|
+
Since it's an object, the errors will contain attribute names
|
830
|
+
|
831
|
+
```ruby
|
832
|
+
{
|
833
|
+
password: [
|
834
|
+
"is not string",
|
835
|
+
"is too short (minimum is 10 characters)"
|
836
|
+
]
|
837
|
+
}
|
838
|
+
```
|
839
|
+
|
840
|
+
### Options
|
841
|
+
|
842
|
+
#### <a name="validator-options-required"></a> `:required`
|
843
|
+
|
844
|
+
*Value: `Boolean`* - Default is `false`
|
845
|
+
|
846
|
+
This option makes the attribute mandatory.
|
847
|
+
|
848
|
+
```ruby
|
849
|
+
schema :hero do
|
850
|
+
string :name, required: true
|
851
|
+
end
|
852
|
+
|
853
|
+
hero = Hero.new
|
854
|
+
validator.validate(hero_schema, hero) # => Result.errors
|
855
|
+
```
|
856
|
+
|
857
|
+
will return
|
858
|
+
|
859
|
+
```ruby
|
860
|
+
{
|
861
|
+
name: ["can't be blank"]
|
862
|
+
}
|
863
|
+
```
|
864
|
+
|
865
|
+
#### <a name="validator-options-match"></a> `:match`
|
866
|
+
|
867
|
+
*Value: `Regexp`* - Regular Expression to be tested.
|
868
|
+
|
869
|
+
This option will test if string matches against given regular expression or not.
|
870
|
+
|
871
|
+
```ruby
|
872
|
+
schema :hero do
|
873
|
+
string :name, match: /\A[a-zA-Z]+\z/
|
874
|
+
end
|
875
|
+
|
876
|
+
hero = Hero.new(name: "Mark 3")
|
877
|
+
validator.validate(hero_schema, hero) # => Result.errors
|
878
|
+
```
|
879
|
+
|
880
|
+
will return
|
881
|
+
|
882
|
+
```ruby
|
883
|
+
{
|
884
|
+
name: ["is not valid"]
|
885
|
+
}
|
886
|
+
```
|
887
|
+
|
888
|
+
#### <a name="validator-options-min-max"></a> `:min/:max`
|
889
|
+
|
890
|
+
*Value: `Number`* - Apply to both `string` and `number` attribute types.
|
891
|
+
|
892
|
+
This option sets a minimum and maximum value for attribute.
|
893
|
+
|
894
|
+
```ruby
|
895
|
+
schema :hero do
|
896
|
+
string :power, max: 100
|
897
|
+
end
|
898
|
+
|
899
|
+
hero = Hero.new(power: 200)
|
900
|
+
validator.validate(hero_schema, hero) # => Result.errors
|
901
|
+
```
|
902
|
+
|
903
|
+
will return
|
904
|
+
|
905
|
+
```ruby
|
906
|
+
{
|
907
|
+
power: ["must be less than or equal to 100"]
|
908
|
+
}
|
909
|
+
```
|
910
|
+
|
911
|
+
#### <a name="validator-options-in"></a> `:in`
|
912
|
+
|
913
|
+
*Value: `Array`* - Set of valid values.
|
914
|
+
|
915
|
+
This option will test if value is in the specified list or not.
|
916
|
+
|
917
|
+
```ruby
|
918
|
+
schema :hero do
|
919
|
+
string :status, in: ["Happy", "Angry"]
|
920
|
+
end
|
921
|
+
|
922
|
+
hero = Hero.new(status: "Mad")
|
923
|
+
validator.validate(hero_schema, hero) # => Result.errors
|
924
|
+
```
|
925
|
+
|
926
|
+
will return
|
927
|
+
|
928
|
+
```ruby
|
929
|
+
{
|
930
|
+
status: ["is not included in the list"]
|
931
|
+
}
|
932
|
+
```
|
933
|
+
|
934
|
+
### <a name="validator-condition"></a> Conditional Validation
|
935
|
+
#### <a name="validator-options-if-unless"></a> `:if/:unless`
|
936
|
+
|
937
|
+
*Possible value: `Symbol`/`Proc`*
|
938
|
+
|
939
|
+
This enables us to validate attribute based on `if/unless` conditions.
|
940
|
+
|
941
|
+
```ruby
|
942
|
+
schema :hero do
|
943
|
+
string :name
|
944
|
+
|
945
|
+
# or we can use `Proc` e.g.
|
946
|
+
# if: ->(x) { x.is_agent }
|
947
|
+
string :agent_name, required: true, if: :is_agent
|
948
|
+
|
949
|
+
boolean :agent, default: false
|
950
|
+
end
|
951
|
+
|
952
|
+
hero = Hero.new(name: "Nick Fury", is_agent: true)
|
953
|
+
validator.validate(hero_schema, hero) # Result.errors =>
|
954
|
+
```
|
955
|
+
|
956
|
+
will produce (because `is_agent` is `true`)
|
957
|
+
|
958
|
+
```ruby
|
959
|
+
{
|
960
|
+
agent_name: ["can't be blank"]
|
961
|
+
}
|
962
|
+
```
|
963
|
+
|
964
|
+
### Custom Validator
|
965
|
+
|
966
|
+
Since the validator is based on `ActiveModel::Validations`. This make it easy to add a new custom validator.
|
967
|
+
|
968
|
+
In the following example. We create a simple password validator and register it to the password attribute.
|
969
|
+
|
970
|
+
```ruby
|
971
|
+
# Defines custom validator
|
972
|
+
class CustomPasswordValidator < ActiveModel::Validator
|
973
|
+
def validate(record)
|
974
|
+
record.errors.add(:password, "is too short") unless record.password.length > 10
|
975
|
+
end
|
976
|
+
end
|
977
|
+
|
978
|
+
# Registers
|
979
|
+
schema :user do
|
980
|
+
string :username
|
981
|
+
string :password, validators: [CustomPasswordValidator]
|
982
|
+
end
|
983
|
+
```
|
984
|
+
|
985
|
+
### Custom Normalizer
|
986
|
+
|
987
|
+
It's possible to change the logic for normalizing data before passing it to the validator. For example, you might want to normalize `boolean` value before validating it.
|
988
|
+
|
989
|
+
Here, we want to normalize string such as `on` or `1` to boolean `true` first.
|
990
|
+
|
991
|
+
```ruby
|
992
|
+
# Overrides default normalizer
|
993
|
+
Boolean.normalizer = ->(x) { [true, 1, "on"].include?(x) ? true : false }
|
994
|
+
|
995
|
+
schema :hero do
|
996
|
+
boolean :berserk
|
997
|
+
end
|
998
|
+
|
999
|
+
hero = Hero.new(berserk: 1)
|
1000
|
+
validator.validate(hero_schema, hero) # Result.value
|
1001
|
+
```
|
1002
|
+
will pass the validation and `Result.value`'ll contain normalized value
|
1003
|
+
|
1004
|
+
```ruby
|
1005
|
+
{
|
1006
|
+
berserk: true # convert 1 to true
|
1007
|
+
}
|
1008
|
+
```
|
1009
|
+
|
1010
|
+
### Validating `Endpoint`
|
1011
|
+
|
1012
|
+
All examples above also apply to endpoint's parameters validation.
|
1013
|
+
|
1014
|
+
Because the params is part of `Endpoint` and it's non-trivial task to retrieve endpoint's method's params. So, the `Endpoint` provides some helper methods to validate the data.
|
1015
|
+
|
1016
|
+
```ruby
|
1017
|
+
resolver = Tomyum::Resolver.new do
|
1018
|
+
endpoint "/heroes" do
|
1019
|
+
post :create, params: lambda {
|
1020
|
+
string :name, required: true
|
1021
|
+
}
|
1022
|
+
end
|
1023
|
+
end
|
1024
|
+
|
1025
|
+
# :create is a POST method name given to the endpoint.
|
1026
|
+
resolver.endpoint(:heroes).method(:create, resolver: resolver).validate(payload) # => Result
|
1027
|
+
```
|
1028
|
+
|
1029
|
+
## Generating Open API (v3)
|
1030
|
+
|
1031
|
+
Once we have all schemas/endpoints defined and registered to the `Resolver`. We can simply pass it to the generator as follows.
|
1032
|
+
|
1033
|
+
```ruby
|
1034
|
+
generator = Tomyum::Generators::OpenAPI::Generator.new(resolver, options)
|
1035
|
+
generator.generate # =>
|
1036
|
+
```
|
1037
|
+
|
1038
|
+
will output Open API (v3) specs
|
1039
|
+
|
1040
|
+
*excerpt from the full result*
|
1041
|
+
```js
|
1042
|
+
// endpoints
|
1043
|
+
"/users": {
|
1044
|
+
"post": {
|
1045
|
+
"requestBody": {
|
1046
|
+
"content": {
|
1047
|
+
"application/x-www-form-urlencoded": {
|
1048
|
+
"schema": {
|
1049
|
+
"type": "object",
|
1050
|
+
"properties": {
|
1051
|
+
"username": {
|
1052
|
+
"type": "string"
|
1053
|
+
},
|
1054
|
+
"accepted": {
|
1055
|
+
"type": "boolean",
|
1056
|
+
"enum": [true, false]
|
1057
|
+
},
|
1058
|
+
}
|
1059
|
+
}
|
1060
|
+
}
|
1061
|
+
}
|
1062
|
+
},
|
1063
|
+
"responses": {
|
1064
|
+
"200": {
|
1065
|
+
"content": {
|
1066
|
+
"application/json": {
|
1067
|
+
"schema": {
|
1068
|
+
"$ref": "#/components/schemas/user"
|
1069
|
+
}
|
1070
|
+
}
|
1071
|
+
}
|
1072
|
+
}
|
1073
|
+
}
|
1074
|
+
}
|
1075
|
+
}
|
1076
|
+
|
1077
|
+
// schema
|
1078
|
+
"user": {
|
1079
|
+
"type": "object",
|
1080
|
+
"properties": {
|
1081
|
+
"address": {
|
1082
|
+
"type": "object",
|
1083
|
+
"properties": {
|
1084
|
+
"city": {
|
1085
|
+
"type": "string",
|
1086
|
+
"default": "Bangkok"
|
1087
|
+
}
|
1088
|
+
}
|
1089
|
+
},
|
1090
|
+
"friends": {
|
1091
|
+
"type": "array",
|
1092
|
+
"items": {
|
1093
|
+
"$ref": "#/components/schemas/user"
|
1094
|
+
}
|
1095
|
+
}
|
1096
|
+
}
|
1097
|
+
}
|
1098
|
+
```
|
1099
|
+
|
1100
|
+
## Contributing
|
1101
|
+
|
1102
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rezigned/tomyum. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
1103
|
+
|
1104
|
+
## License
|
1105
|
+
|
1106
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|